diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000000..b8f75aaf4db --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,24 @@ +version: 2 +jobs: + minimal_core: + machine: + enabled: true + steps: + - checkout + - run: + command: ./gradlew --no-daemon --continue --scan testcontainers:test --tests '*GenericContainerRuleTest' + - run: + name: Save test results + command: | + mkdir -p ~/junit/ + find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/junit/ \; + when: always + - store_test_results: + path: ~/junit + - store_artifacts: + path: ~/junit +workflows: + version: 2 + test_all: + jobs: + - minimal_core diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000000..9131ce06da3 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,42 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.241.1/containers/java-8 +{ + "name": "Java 17", + "image": "mcr.microsoft.com/devcontainers/java:0-17", + + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "vscjava.vscode-java-pack" + ] + } + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "java -version", + + // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/meaningful-ooo/devcontainer-features/homebrew:2": {}, + "ghcr.io/devcontainers/features/java:1": { + "version": "none", + "installMaven": "false", + "installGradle": "false" + }, + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, + "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/devcontainers/features/sshd:1": { + "version": "latest" + } + }, + "postStartCommand": ["./gradlew", "compileJava"] +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..b4910e47661 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,28 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.java] +indent_style = space +indent_size = 4 +# Never use star imports +ij_java_names_count_to_use_import_on_demand = 99 +ij_java_class_count_to_use_import_on_demand = 99 +ij_java_layout_static_imports_separately = true + +[*.{yml, yaml}] +indent_size = 2 +ij_yaml_keep_indents_on_empty_lines = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..526c8a38d4a --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..6f0c579e964 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @testcontainers/java-team diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000000..9889f256efc --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [testcontainers] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 00000000000..c31dd05e048 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,138 @@ +name: Bug Report +description: File a bug report +title: "[Bug]: " +labels: ["type/bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! Before submitting a `bug`, please make sure there is no existing issue for the one you encountered and it has been discussed with the team via [discussions](https://github.com/testcontainers/testcontainers-java/discussions) or Slack. + - type: dropdown + id: module + attributes: + label: Module + description: Which Testcontainers module are you using? + options: + - Core + - ActiveMQ + - Azure + - Cassandra + - ChromaDB + - Clickhouse + - CockroachDB + - Consul + - Couchbase + - CrateDB + - Databend + - DB2 + - Dynalite + - Elasticsearch + - GCloud + - Grafana + - HiveMQ + - InfluxDB + - K3S + - K6 + - Kafka + - LDAP + - LocalStack + - MariaDB + - Milvus + - MinIO + - MockServer + - MongoDB + - MSSQLServer + - MySQL + - Neo4j + - NGINX + - OceanBase + - Ollama + - OpenFGA + - Oracle Free + - Oracle XE + - OrientDB + - Pinecone + - PostgreSQL + - Presto + - Pulsar + - Qdrant + - QuestDB + - RabbitMQ + - Redpanda + - ScyllaDB + - Selenium + - Solace + - Solr + - TiDB + - Timeplus + - ToxiProxy + - Trino + - Typesense + - Vault + - Weaviate + - YugabyteDB + validations: + required: true + - type: input + id: tc-version + attributes: + label: Testcontainers version + description: Which Testcontainers version are you using? + placeholder: ex. 1.17.2 + validations: + required: true + - type: dropdown + id: latest-version + attributes: + label: Using the latest Testcontainers version? + description: If you are not using the latest version, can you update your project and try to reproduce the issue? Is it still happening? + options: + - 'Yes' + - 'No' + validations: + required: true + - type: input + id: host-os + attributes: + label: Host OS + description: Which Operating System are you using? + placeholder: e.g. Linux, Windows + validations: + required: true + - type: input + id: host-arch + attributes: + label: Host Arch + description: Which architecture are you using? + placeholder: e.g. x86, ARM + validations: + required: true + - type: textarea + id: docker-version + attributes: + label: Docker version + description: Please run `docker version` and copy and paste the output into this field. + render: shell + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Provide the context and the expected result. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Please copy and paste any relevant log output. The content will be automatically formatted as code, so no need for backticks. + render: shell + - type: textarea + id: additional-information + attributes: + label: Additional Information + description: | + Any links or references to have more context about the issue. + + Tip: You can attach a minimal sample project to reproduce the issue or provide further log files by clicking into this area to focus it and then dragging files in. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000000..418d5a62fe9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Need help or have a question? + url: https://slack.testcontainers.org/ + about: Visit our slack channel. + - name: Have a question or want to drive a Community conversation? + url: https://github.com/testcontainers/testcontainers-java/discussions/ + about: Visit our Discussions page. diff --git a/.github/ISSUE_TEMPLATE/enhancement.yaml b/.github/ISSUE_TEMPLATE/enhancement.yaml new file mode 100644 index 00000000000..9b9a06ecf6a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement.yaml @@ -0,0 +1,82 @@ +name: Enhancement +description: Suggest an enhancement +title: "[Enhancement]: " +labels: ["type/enhancement"] +body: + - type: markdown + attributes: + value: | + Before submitting an `enhancement`, please make sure there is no existing enhancement for the one you are requesting and it has been discussed with the team via [discussions](https://github.com/testcontainers/testcontainers-java/discussions) or Slack. If so, please provide the following information: + - type: dropdown + id: module + attributes: + label: Module + description: For which Testcontainers module does the enhancement proposal apply? + options: + - Core + - ActiveMQ + - Azure + - Cassandra + - ChromaDB + - Clickhouse + - CockroachDB + - Consul + - Couchbase + - CrateDB + - Databend + - DB2 + - Dynalite + - Elasticsearch + - GCloud + - Grafana + - HiveMQ + - InfluxDB + - K3S + - K6 + - Kafka + - LDAP + - LocalStack + - MariaDB + - Milvus + - MinIO + - MockServer + - MongoDB + - MSSQLServer + - MySQL + - Neo4j + - NGINX + - OceanBase + - Ollama + - OpenFGA + - Oracle Free + - Oracle XE + - OrientDB + - Pinecone + - PostgreSQL + - Presto + - Pulsar + - Qdrant + - QuestDB + - RabbitMQ + - Redpanda + - ScyllaDB + - Selenium + - Solace + - Solr + - TiDB + - Timeplus + - ToxiProxy + - Trino + - Typesense + - Vault + - Weaviate + - YugabyteDB + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: What should be improved? What are the limitations of the current implications that would be solved by the proposal? + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature.yaml b/.github/ISSUE_TEMPLATE/feature.yaml new file mode 100644 index 00000000000..b655b4ac505 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yaml @@ -0,0 +1,111 @@ +name: Feature +description: Suggest a new feature +title: "[Feature]: " +labels: ["type/feature"] +body: + - type: markdown + attributes: + value: | + Before submitting a `feature`, please make sure there is no existing feature for the one you are requesting and it has been discussed with the team via [discussions](https://github.com/testcontainers/testcontainers-java/discussions) or Slack. If so, please provide the following information: + - type: dropdown + id: module + attributes: + label: Module + description: Is this feature related to any of the existing modules? + options: + - Core + - ActiveMQ + - Azure + - Cassandra + - ChromaDB + - Clickhouse + - CockroachDB + - CrateDB + - Consul + - Couchbase + - Databend + - DB2 + - Dynalite + - Elasticsearch + - GCloud + - Grafana + - HiveMQ + - InfluxDB + - K3S + - K6 + - Kafka + - LDAP + - LocalStack + - MariaDB + - Milvus + - MinIO + - MockServer + - MongoDB + - MSSQLServer + - MySQL + - Neo4j + - NGINX + - OceanBase + - Ollama + - OpenFGA + - Oracle Free + - Oracle XE + - OrientDB + - Pinecone + - PostgreSQL + - Qdrant + - QuestDB + - Presto + - Pulsar + - RabbitMQ + - Redpanda + - ScyllaDB + - Selenium + - Solace + - Solr + - TiDB + - Timeplus + - ToxiProxy + - Trino + - Typesense + - Vault + - Weaviate + - YugabyteDB + - New Module + - type: textarea + id: problem + attributes: + label: Problem + description: Is this feature related to a problem? Please describe it. + validations: + required: true + - type: textarea + id: solution + attributes: + label: Solution + description: What's the proposed solution for this feature? + validations: + required: true + - type: textarea + id: benefit + attributes: + label: Benefit + description: What's the benefit of adding this feature to the project? + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives + description: Are there other alternatives? Please describe them. + validations: + required: true + - type: dropdown + id: contribute + attributes: + label: Would you like to help contributing this feature? + options: + - 'Yes' + - 'No' + validations: + required: true diff --git a/.github/actions/setup-build/action.yml b/.github/actions/setup-build/action.yml new file mode 100644 index 00000000000..0ab491cbb20 --- /dev/null +++ b/.github/actions/setup-build/action.yml @@ -0,0 +1,17 @@ +name: Set up Build +description: Sets up Build +inputs: + java-version: + description: 'The Java version to set up' + required: true + default: '17' +runs: + using: "composite" + steps: + - uses: ./.github/actions/setup-java + with: + java-version: ${{ inputs.java-version }} + - name: Clear existing docker image cache + shell: bash + run: docker image prune -af + - uses: ./.github/actions/setup-gradle diff --git a/.github/actions/setup-gradle/action.yml b/.github/actions/setup-gradle/action.yml new file mode 100644 index 00000000000..76d1b0ff4b2 --- /dev/null +++ b/.github/actions/setup-gradle/action.yml @@ -0,0 +1,12 @@ +name: Set up Gradle Action +description: Sets up Gradle Action +runs: + using: "composite" + steps: + - name: Setup Gradle Build Action + uses: gradle/actions/setup-gradle@v6 + with: + gradle-home-cache-includes: | + caches + notifications + jdks diff --git a/.github/actions/setup-java/action.yml b/.github/actions/setup-java/action.yml new file mode 100644 index 00000000000..093fbc2e7fd --- /dev/null +++ b/.github/actions/setup-java/action.yml @@ -0,0 +1,14 @@ +name: Set up Java +description: Sets up Java version +inputs: + java-version: + description: 'The Java version to set up' + required: true + default: '17' +runs: + using: "composite" + steps: + - uses: actions/setup-java@v5 + with: + java-version: ${{ inputs.java-version }} + distribution: temurin diff --git a/.github/actions/setup-junit-report/action.yml b/.github/actions/setup-junit-report/action.yml new file mode 100644 index 00000000000..df0af39297f --- /dev/null +++ b/.github/actions/setup-junit-report/action.yml @@ -0,0 +1,11 @@ +name: Set up JUnit Report +description: Sets up JUnit Report +runs: + using: "composite" + steps: + - name: Publish Test Report + uses: mikepenz/action-junit-report@v6 + if: always() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/test/TEST-*.xml' + annotate_only: true diff --git a/.github/bumper.yml b/.github/bumper.yml new file mode 100644 index 00000000000..bb9bca7d29a --- /dev/null +++ b/.github/bumper.yml @@ -0,0 +1,3 @@ +updates: +- path: mkdocs.yml + pattern: 'latest_version: (.*)' diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..789ec351da2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,489 @@ +version: 2 +registries: + gradle-plugin-portal: + type: maven-repository + url: https://plugins.gradle.org/m2 + username: dummy # Required by dependabot + password: dummy # Required by dependabot +updates: + - package-ecosystem: "gradle" + directory: "/core" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "org.slf4j:slf4j-api" + update-types: [ "version-update:semver-major" ] + - dependency-name: "org.mockito:mockito-core" + update-types: [ "version-update:semver-major" ] + - dependency-name: "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" + update-types: [ "version-update:semver-minor", "version-update:semver-patch" ] + - dependency-name: "org.junit.jupiter:junit-jupiter" + update-types: [ "version-update:semver-major" ] + - dependency-name: "org.junit.platform:junit-platform-launcher" + update-types: [ "version-update:semver-major" ] + - dependency-name: "io.rest-assured:rest-assured" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/" + allow: + - dependency-name: "com.gradle*" + registries: + - gradle-plugin-portal + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "com.gradleup.shadow" + update-types: [ "version-update:semver-major" ] + - dependency-name: "org.junit.jupiter:junit-jupiter" + update-types: [ "version-update:semver-major" ] + - dependency-name: "org.junit.platform:junit-platform-launcher" + update-types: [ "version-update:semver-major" ] + +# Explicit entry for each module + - package-ecosystem: "gradle" + directory: "/modules/activemq" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/azure" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/cassandra" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "io.dropwizard.metrics:metrics-core" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/chromadb" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "io.rest-assured:rest-assured" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/clickhouse" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/cockroachdb" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/consul" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "io.rest-assured:rest-assured" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/couchbase" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/cratedb" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/database-commons" + schedule: + interval: "monthly" + - package-ecosystem: "gradle" + directory: "/modules/databend" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/db2" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/dynalite" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/elasticsearch" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/gcloud" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/grafana" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "io.rest-assured:rest-assured" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/hivemq" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/influxdb" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "com.influxdb:influxdb-java-client" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/jdbc" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "org.mockito:mockito-core" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/jdbc-test" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "org.apache.tomcat:tomcat-jdbc" + update-types: [ "version-update:semver-minor" ] + - dependency-name: "org.junit.jupiter:junit-jupiter" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/junit-jupiter" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "org.mockito:mockito-core" + update-types: [ "version-update:semver-major" ] + - dependency-name: "org.junit:junit-bom" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/k3s" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml" + update-types: [ "version-update:semver-minor", "version-update:semver-patch" ] + - package-ecosystem: "gradle" + directory: "/modules/k6" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/kafka" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/ldap" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/localstack" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/mariadb" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "org.mariadb:r2dbc-mariadb" + update-types: [ "version-update:semver-minor" ] + - package-ecosystem: "gradle" + directory: "/modules/milvus" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/minio" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/mockserver" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "io.rest-assured:rest-assured" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/mongodb" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/mssqlserver" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/mysql" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/neo4j" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "org.neo4j.driver:neo4j-java-driver" + update-types: [ "version-update:semver-major" ] + - dependency-name: "org.neo4j:neo4j" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/nginx" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/oceanbase" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/ollama" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "io.rest-assured:rest-assured" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/openfga" + schedule: + interval: "monthly" + - package-ecosystem: "gradle" + directory: "/modules/oracle-free" + schedule: + interval: "monthly" + - package-ecosystem: "gradle" + directory: "/modules/oracle-xe" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/orientdb" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/postgresql" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/presto" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/pinecone" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "io.pinecone:pinecone-client" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/pulsar" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "org.apache.pulsar:pulsar-bom" + update-types: [ "version-update:semver-patch" ] + - package-ecosystem: "gradle" + directory: "/modules/qdrant" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/questdb" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/r2dbc" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "io.r2dbc:r2dbc-spi" + update-types: [ "version-update:semver-major", "version-update:semver-minor" ] + - dependency-name: "org.junit.jupiter:junit-jupiter" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/rabbitmq" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/redpanda" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "io.rest-assured:rest-assured" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/scylladb" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/selenium" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "org.seleniumhq.selenium:selenium-bom" + update-types: [ "version-update:semver-minor" ] + - package-ecosystem: "gradle" + directory: "/modules/solace" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "org.apache.qpid:qpid-jms-client" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/solr" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "org.apache.solr:solr-solrj" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/spock" + schedule: + interval: "monthly" + ignore: + - dependency-name: "org.junit:junit-bom" + update-types: [ "version-update:semver-major" ] + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/tidb" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/timeplus" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/toxiproxy" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/trino" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/typesense" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/vault" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "io.rest-assured:rest-assured" + update-types: [ "version-update:semver-major" ] + - package-ecosystem: "gradle" + directory: "/modules/weaviate" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/yugabytedb" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + +# Examples + - package-ecosystem: "gradle" + directory: "/examples" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "ch.qos.logback:logback-classic" + update-types: [ "version-update:semver-minor" ] + - dependency-name: "org.apache.solr:solr-solrj" + update-types: [ "version-update:semver-major" ] + - dependency-name: "org.neo4j.driver:neo4j-java-driver" + update-types: [ "version-update:semver-major" ] + - dependency-name: "org.testng:testng" + update-types: [ "version-update:semver-minor" ] + - dependency-name: "org.slf4j:slf4j-api" + update-types: [ "version-update:semver-major" ] + - dependency-name: "org.springframework.boot" + update-types: [ "version-update:semver-major" ] + - dependency-name: "com.diffplug.spotless" + update-types: [ "version-update:semver-major", "version-update:semver-minor" ] + - dependency-name: "com.hazelcast:hazelcast" + update-types: [ "version-update:semver-minor" ] + - dependency-name: "org.junit.jupiter:junit-jupiter" + update-types: [ "version-update:semver-major" ] + - dependency-name: "org.junit.platform:junit-platform-launcher" + update-types: [ "version-update:semver-major" ] + - dependency-name: "org.junit:junit-bom" + update-types: [ "version-update:semver-major" ] + - dependency-name: "com.gradleup.shadow" + update-types: [ "version-update:semver-major" ] + +# Smoke test + - package-ecosystem: "gradle" + directory: "/smoke-test" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "ch.qos.logback:logback-classic" + update-types: [ "version-update:semver-minor" ] + - dependency-name: "com.diffplug.spotless" + update-types: [ "version-update:semver-major", "version-update:semver-minor" ] + - dependency-name: "org.junit.jupiter:junit-jupiter" + update-types: [ "version-update:semver-major" ] + - dependency-name: "org.junit.platform:junit-platform-launcher" + update-types: [ "version-update:semver-major" ] + - dependency-name: "com.gradleup.shadow" + update-types: [ "version-update:semver-major" ] + +# GitHub Actions + - package-ecosystem: "github-actions" + directories: + - /.github/workflows + - /.github/actions/** + schedule: + interval: "weekly" + open-pull-requests-limit: 10 diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000000..f4649bd7f99 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,266 @@ +"area/docker-compose": + - changed-files: + - any-glob-to-any-file: + - core/src/main/java/org/testcontainers/containers/ComposeContainer.java + - core/src/main/java/org/testcontainers/containers/ComposeDelegate.java + - core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java + - core/src/main/java/org/testcontainers/containers/DockerComposeFiles.java + - core/src/test/java/org/testcontainers/containers/Compose*Test.java + - core/src/test/java/org/testcontainers/containers/DockerCompose*Test.java + - core/src/test/java/org/testcontainers/junit/Compose*Test.java + - core/src/test/java/org/testcontainers/junit/DockerCompose*Test.java +"github_actions": + - changed-files: + - any-glob-to-any-file: + - .github/workflows/* +"gradle-wrapper": + - changed-files: + - any-glob-to-any-file: + - gradle/wrapper/* + - gradlew + - gradlew.bat +"modules/activemq": + - changed-files: + - any-glob-to-any-file: + - modules/activemq/**/* +"modules/azure": + - changed-files: + - any-glob-to-any-file: + - modules/azure/**/* +"modules/cassandra": + - changed-files: + - any-glob-to-any-file: + - modules/cassandra/**/* +"modules/chromadb": + - changed-files: + - any-glob-to-any-file: + - modules/chromadb/**/* +"modules/clickhouse": + - changed-files: + - any-glob-to-any-file: + - modules/clickhouse/**/* +"modules/cockroachdb": + - changed-files: + - any-glob-to-any-file: + - modules/cockroachdb/**/* +"modules/consul": + - changed-files: + - any-glob-to-any-file: + - modules/consul/**/* +"modules/couchbase": + - changed-files: + - any-glob-to-any-file: + - modules/couchbase/**/* +"modules/cratedb": + - changed-files: + - any-glob-to-any-file: + - modules/cratedb/**/* +"modules/databend": + - changed-files: + - any-glob-to-any-file: + - modules/databend/**/* +"modules/db2": + - changed-files: + - any-glob-to-any-file: + - modules/db2/**/* +"modules/dynalite": + - changed-files: + - any-glob-to-any-file: + - modules/dynalite/**/* +"modules/elasticsearch": + - changed-files: + - any-glob-to-any-file: + - modules/elasticsearch/**/* +"modules/gcloud": + - changed-files: + - any-glob-to-any-file: + - modules/gcloud/**/* +"modules/grafana": + - changed-files: + - any-glob-to-any-file: + - modules/grafana/**/* +"modules/hivemq": + - changed-files: + - any-glob-to-any-file: + - modules/hivemq/**/* +"modules/influx": + - changed-files: + - any-glob-to-any-file: + - modules/influxdb/**/* +"modules/jdbc": + - changed-files: + - any-glob-to-any-file: + - modules/jdbc/**/* +"modules/jupiter": + - changed-files: + - any-glob-to-any-file: + - modules/junit-jupiter/**/* +"modules/k3s": + - changed-files: + - any-glob-to-any-file: + - modules/k3s/**/* +"modules/k6": + - changed-files: + - any-glob-to-any-file: + - modules/k6/**/* +"modules/kafka": + - changed-files: + - any-glob-to-any-file: + - modules/kafka/**/* +"modules/ldap": + - changed-files: + - any-glob-to-any-file: + - modules/ldap/**/* +"modules/localstack": + - changed-files: + - any-glob-to-any-file: + - modules/localstack/**/* +"modules/mariadb": + - changed-files: + - any-glob-to-any-file: + - modules/mariadb/**/* +"modules/milvus": + - changed-files: + - any-glob-to-any-file: + - modules/milvus/**/* +"modules/minio": + - changed-files: + - any-glob-to-any-file: + - modules/minio/**/* +"modules/mockserver": + - changed-files: + - any-glob-to-any-file: + - modules/mockserver/**/* +"modules/mongodb": + - changed-files: + - any-glob-to-any-file: + - modules/mongodb/**/* +"modules/sql-server": + - changed-files: + - any-glob-to-any-file: + - modules/mssqlserver/**/* +"modules/mysql": + - changed-files: + - any-glob-to-any-file: + - modules/mysql/**/* +"modules/neo4j": + - changed-files: + - any-glob-to-any-file: + - modules/neo4j/**/* +"modules/nginx": + - changed-files: + - any-glob-to-any-file: + - modules/nginx/**/* +"modules/oceanbase": + - changed-files: + - any-glob-to-any-file: + - modules/oceanbase/**/* +"modules/ollama": + - changed-files: + - any-glob-to-any-file: + - modules/ollama/**/* +"modules/openfga": + - changed-files: + - any-glob-to-any-file: + - modules/openfga/**/* +"modules/oracle": + - changed-files: + - any-glob-to-any-file: + - modules/oracle-free/**/* + - modules/oracle-xe/**/* +"modules/orientdb": + - changed-files: + - any-glob-to-any-file: + - modules/orientdb/**/* +"modules/pinecone": + - changed-files: + - any-glob-to-any-file: + - modules/pinecone/**/* +"modules/postgres": + - changed-files: + - any-glob-to-any-file: + - modules/postgresql/**/* +"modules/presto": + - changed-files: + - any-glob-to-any-file: + - modules/presto/**/* +"modules/pulsar": + - changed-files: + - any-glob-to-any-file: + - modules/pulsar/**/* +"modules/qdrant": + - changed-files: + - any-glob-to-any-file: + - modules/qdrant/**/* +"modules/questdb": + - changed-files: + - any-glob-to-any-file: + - modules/questdb/**/* +"modules/r2dbc": + - changed-files: + - any-glob-to-any-file: + - modules/r2dbc/**/* +"modules/rabbitmq": + - changed-files: + - any-glob-to-any-file: + - modules/rabbitmq/**/* +"modules/redpanda": + - changed-files: + - any-glob-to-any-file: + - modules/redpanda/**/* +"modules/scylladb": + - changed-files: + - any-glob-to-any-file: + - modules/scylladb/**/* +"modules/selenium": + - changed-files: + - any-glob-to-any-file: + - modules/selenium/**/* +"modules/solace": + - changed-files: + - any-glob-to-any-file: + - modules/solace/**/* +"modules/solr": + - changed-files: + - any-glob-to-any-file: + - modules/solr/**/* +"modules/spock": + - changed-files: + - any-glob-to-any-file: + - modules/spock/**/* +"modules/tidb": + - changed-files: + - any-glob-to-any-file: + - modules/tidb/**/* +"modules/timeplus": + - changed-files: + - any-glob-to-any-file: + - modules/timeplus/**/* +"modules/toxiproxy": + - changed-files: + - any-glob-to-any-file: + - modules/toxiproxy/**/* +"modules/trino": + - changed-files: + - any-glob-to-any-file: + - modules/trino/**/* +"modules/typesense": + - changed-files: + - any-glob-to-any-file: + - modules/typesense/**/* +"modules/vault": + - changed-files: + - any-glob-to-any-file: + - modules/vault/**/* +"modules/weaviate": + - changed-files: + - any-glob-to-any-file: + - modules/weaviate/**/* +"modules/yugabytedb": + - changed-files: + - any-glob-to-any-file: + - modules/yugabytedb/**/* +"type/docs": + - changed-files: + - any-glob-to-any-file: + - docs/**/*.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000000..6137bf623e3 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,34 @@ + diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 00000000000..07376824bf7 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,26 @@ +name-template: $NEXT_PATCH_VERSION +tag-template: $NEXT_PATCH_VERSION +template: | + # What's Changed + + $CHANGES +categories: + - title: ⚠️ Breaking API changes + label: type/breaking-api-change + - title: 🚀 Features & Enhancements + labels: + - type/feature + - type/enhancement + - title: ☠️ Deprecations + label: type/deprecation + - title: 🐛 Bug Fixes + label: type/bug + - title: 📖 Documentation + label: type/docs + - title: 🧹 Housekeeping + labels: + - type/housekeeping + - type/test-improvement + - title: 📦 Dependency updates + label: dependencies + collapse-after: 5 diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 00000000000..63c8afacb90 --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,418 @@ +# These settings are synced to GitHub by https://probot.github.io/apps/settings/ + +repository: + # See https://docs.github.com/en/rest/reference/repos#update-a-repository for all available settings. + + # The name of the repository. Changing this will rename the repository + name: testcontainers-java + + # A short description of the repository that will show up on GitHub + description: Testcontainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container. + + # A URL with more information about the repository + homepage: https://testcontainers.org + + # A comma-separated list of topics to set on the repository + topics: java,testing,docker,docker-compose,jvm,test-automation,junit,hacktoberfest,integration-testing + + # Either `true` to make the repository private, or `false` to make it public. + private: false + + # Either `true` to enable issues for this repository, `false` to disable them. + has_issues: true + + # Either `true` to enable projects for this repository, or `false` to disable them. + # If projects are disabled for the organization, passing `true` will cause an API error. + has_projects: false + + # Either `true` to enable the wiki for this repository, `false` to disable it. + has_wiki: false + + # Either `true` to enable downloads for this repository, `false` to disable them. + has_downloads: false + + # Updates the default branch for this repository. + default_branch: main + + # Either `true` to allow squash-merging pull requests, or `false` to prevent + # squash-merging. + allow_squash_merge: true + + # Either `true` to allow merging pull requests with a merge commit, or `false` + # to prevent merging pull requests with merge commits. + allow_merge_commit: false + + # Either `true` to allow rebase-merging pull requests, or `false` to prevent + # rebase-merging. + allow_rebase_merge: false + + # Either `true` to enable automatic deletion of branches on merge, or `false` to disable + delete_branch_on_merge: true + +# Labels: define labels for Issues and Pull Requests +# If including a `#`, make sure to wrap it with quotes! +labels: + - name: area/bitbucket-pipelines + color: '#f9d0c4' + + - name: area/docker-compose + color: '#f9d0c4' + + - name: area/logging + color: '#f9d0c4' + + - name: area/shading + color: '#f9d0c4' + + - name: area/test frameworks + color: '#f9d0c4' + + - name: blocker + color: '#b60205' + + - name: client/docker-for-mac + color: '#c2e0c6' + + - name: client/docker-for-windows + color: '#c2e0c6' + + - name: client/docker-machine + color: '#c2e0c6' + + - name: client/in-container + color: '#c2e0c6' + + - name: client/podman + color: '#c2e0c6' + + - name: dependencies + color: '#0025ff' + + - name: github_actions + color: '#000000' + + - name: good first issue + color: '#14d60a' + + - name: gradle-wrapper + color: '#02303A' + + - name: hacktoberfest + color: '#14d60a' + + - name: hacktoberfest-accepted + color: '#79C259' + + - name: help wanted + color: '#fef2c0' + + - name: modules/activemq + color: '#006b75' + + - name: modules/azure + color: '#006b75' + + - name: modules/cassandra + color: '#006b75' + + - name: modules/chromadb + color: '#006b75' + + - name: modules/clickhouse + color: '#006b75' + + - name: modules/cockroachdb + color: '#006b75' + + - name: modules/consul + color: '#006b75' + + - name: modules/couchbase + color: '#006b75' + + - name: modules/cratedb + color: '#006b75' + + - name: modules/db2 + color: '#006b75' + + - name: modules/dynalite + color: '#006b75' + + - name: modules/elasticsearch + color: '#006b75' + + - name: modules/gcloud + color: '#006b75' + + - name: modules/grafana + color: '#006b75' + + - name: modules/hivemq + color: '#006b75' + + - name: modules/influx + color: '#006b75' + + - name: modules/jdbc + color: '#006b75' + + - name: modules/jupiter + color: '#006b75' + + - name: modules/k3s + color: '#006b75' + + - name: modules/k6 + color: '#006b75' + + - name: modules/kafka + color: '#006b75' + + - name: modules/ldap + color: '#006b75' + + - name: modules/localstack + color: '#006b75' + + - name: modules/mariadb + color: '#006b75' + + - name: modules/milvus + color: '#006b75' + + - name: modules/minio + color: '#006b75' + + - name: modules/mockserver + color: '#006b75' + + - name: modules/mongodb + color: '#006b75' + + - name: modules/mysql + color: '#006b75' + + - name: modules/neo4j + color: '#006b75' + + - name: modules/nginx + color: '#006b75' + + - name: modules/oceanbase + color: '#006b75' + + - name: modules/ollama + color: '#006b75' + + - name: modules/openfga + color: '#006b75' + + - name: modules/oracle + color: '#006b75' + + - name: modules/orientdb + color: '#006b75' + + - name: modules/pinecone + color: '#006b75' + + - name: modules/postgres + color: '#006b75' + + - name: modules/presto + color: '#006b75' + + - name: modules/pulsar + color: '#006b75' + + - name: modules/qdrant + color: '#006b75' + + - name: modules/questdb + color: '#006b75' + + - name: modules/r2dbc + color: '#006b75' + + - name: modules/rabbitmq + color: '#006b75' + + - name: modules/redpanda + color: '#006b75' + + - name: modules/selenium + color: '#006b75' + + - name: modules/solace + color: '#006b75' + + - name: modules/solr + color: '#006b75' + + - name: modules/spock + color: '#006b75' + + - name: modules/sql-server + color: '#006b75' + + - name: modules/tidb + color: '#006b75' + + - name: modules/timeplus + color: '#006b75' + + - name: modules/toxiproxy + color: '#006b75' + + - name: modules/trino + color: '#006b75' + + - name: modules/typesense + color: '#006b75' + + - name: modules/vault + color: '#006b75' + + - name: modules/weaviate + color: '#006b75' + + - name: modules/yugabytedb + color: '#006b75' + + - name: modules/databend + color: '#006b75' + + - name: os/linux + color: '#1d76db' + + - name: os/macOS + color: '#1d76db' + + - name: os/windows + color: '#1d76db' + + - name: resolution/acknowledged + color: '#fef2c0' + + - name: resolution/answered + color: '#fef2c0' + + - name: resolution/awaiting-release + color: '#fef2c0' + + - name: resolution/duplicate + color: '#fef2c0' + + - name: resolution/invalid + color: '#fef2c0' + + - name: resolution/pr-submitted + color: '#fef2c0' + + - name: resolution/somedaymaybe + color: '#fef2c0' + + - name: resolution/waiting-for-info + color: '#fef2c0' + + - name: resolution/wontfix + color: '#fef2c0' + + - name: security + color: '#ee0701' + + - name: stale + color: '#ffffff' + + - name: type/breaking-api-change + color: '#d4c5f9' + + - name: type/bug + color: '#d4c5f9' + + - name: type/deprecation + color: '#d4c5f9' + + - name: type/docs + color: '#d4c5f9' + + - name: type/enhancement + color: '#d4c5f9' + + - name: type/feature + color: '#d4c5f9' + + - name: type/housekeeping + color: '#d4c5f9' + + - name: type/new module + color: '#d4c5f9' + + - name: type/question + color: '#d4c5f9' + + - name: type/test-improvement + color: '#d4c5f9' + +# Collaborators: give specific users access to this repository. +# See https://docs.github.com/en/rest/reference/repos#add-a-repository-collaborator for available options +# collaborators: +# - username: +# permission: maintain + + # Note: `permission` is only valid on organization-owned repositories. + # The permission to grant the collaborator. Can be one of: + # * `pull` - can pull, but not push to or administer this repository. + # * `push` - can pull and push, but not administer this repository. + # * `admin` - can pull, push and administer this repository. + # * `maintain` - Recommended for project managers who need to manage the repository without access to sensitive or destructive actions. + # * `triage` - Recommended for contributors who need to proactively manage issues and pull requests without write access. + +# See https://docs.github.com/en/rest/reference/teams#add-or-update-team-repository-permissions for available options +teams: + # Please make sure the team already exist in the organization, as the repository-settings application is not creating them. + # See https://github.com/repository-settings/app/discussions/639 for more information about teams and settings + - name: java-team + # The permission to grant the team. Can be one of: + # * `pull` - can pull, but not push to or administer this repository. + # * `push` - can pull and push, but not administer this repository. + # * `admin` - can pull, push and administer this repository. + # * `maintain` - Recommended for project managers who need to manage the repository without access to sensitive or destructive actions. + # * `triage` - Recommended for contributors who need to proactively manage issues and pull requests without write access. + permission: admin + - name: oss-team + permission: maintain + +branches: + - name: main + # https://docs.github.com/en/rest/reference/repos#update-branch-protection + # Branch Protection settings. Set to null to disable + protection: + # Required. Require at least one approving review on a pull request, before merging. Set to null to disable. + required_pull_request_reviews: + # The number of approvals required. (1-6) + required_approving_review_count: 1 + # Dismiss approved reviews automatically when a new commit is pushed. + dismiss_stale_reviews: true + # Blocks merge until code owners have reviewed. + require_code_owner_reviews: true + # Specify which users and teams can dismiss pull request reviews. Pass an empty dismissal_restrictions object to disable. User and team dismissal_restrictions are only available for organization-owned repositories. Omit this parameter for personal repositories. + dismissal_restrictions: + users: [] + teams: [java-team] + # Required. Require status checks to pass before merging. Set to null to disable + required_status_checks: + # Required. Require branches to be up to date before merging. + strict: true + # Required. The list of status checks to require in order to merge into this branch + contexts: ["core (17)", "core (21)", "check_docs_examples (:docs:examples:check)", "in-docker_test", "ci/circleci: minimal_core", "test"] + # Required. Enforce all configured restrictions for administrators. Set to true to enforce required status checks for repository administrators. Set to null to disable. + enforce_admins: false + # Prevent merge commits from being pushed to matching branches + required_linear_history: true + # Required. Restrict who can push to this branch. Team and user restrictions are only available for organization-owned repositories. Set to null to disable. + restrictions: + apps: [] + users: [] + teams: [java-team] diff --git a/.github/workflows/ci-docker-wormhole.yml b/.github/workflows/ci-docker-wormhole.yml new file mode 100644 index 00000000000..45957af1e28 --- /dev/null +++ b/.github/workflows/ci-docker-wormhole.yml @@ -0,0 +1,57 @@ +name: CI-Docker-Wormhole + +on: + pull_request: + paths-ignore: + - '.github/ISSUE_TEMPLATE/*.yaml' + - '.github/CODEOWNERS' + - '.github/pull_request_template.md' + - 'docs/**/*.css' + - 'docs/**/*.html' + - 'docs/**/*.ico' + - 'docs/**/*.md' + - 'docs/**/*.png' + - 'docs/**/*.svg' + - 'mkdocs.yml' + - 'README.md' + - 'RELEASING.md' + - '.sdkmanrc' + push: + branches: [ main ] + paths-ignore: + - '.github/ISSUE_TEMPLATE/*.yaml' + - '.github/CODEOWNERS' + - '.github/pull_request_template.md' + - 'docs/**/*.css' + - 'docs/**/*.html' + - 'docs/**/*.ico' + - 'docs/**/*.md' + - 'docs/**/*.png' + - 'docs/**/*.svg' + - 'mkdocs.yml' + - 'README.md' + - 'RELEASING.md' + - '.sdkmanrc' + +concurrency: + group: "${{ github.workflow }}-${{ github.head_ref || github.sha }}" + cancel-in-progress: true + +permissions: + contents: read + +jobs: + in-docker_test: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - name: Build with Gradle + run: | + docker run -i --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$HOME:$HOME" \ + -v "$PWD:$PWD" \ + -w "$PWD" \ + -e AUTO_APPLY_GIT_HOOKS=false \ + eclipse-temurin:17-jdk-alpine \ + ./gradlew --no-daemon --continue --scan testcontainers:test --tests '*GenericContainerRuleTest' diff --git a/.github/workflows/ci-rootless.yml b/.github/workflows/ci-rootless.yml new file mode 100644 index 00000000000..ee48b7dc7b6 --- /dev/null +++ b/.github/workflows/ci-rootless.yml @@ -0,0 +1,57 @@ +name: CI-Docker-Rootless + +on: + pull_request: + paths-ignore: + - '.github/ISSUE_TEMPLATE/*.yaml' + - '.github/CODEOWNERS' + - '.github/pull_request_template.md' + - 'docs/**/*.css' + - 'docs/**/*.html' + - 'docs/**/*.ico' + - 'docs/**/*.md' + - 'docs/**/*.png' + - 'docs/**/*.svg' + - 'mkdocs.yml' + - 'README.md' + - 'RELEASING.md' + - '.sdkmanrc' + push: + branches: [ main ] + paths-ignore: + - '.github/ISSUE_TEMPLATE/*.yaml' + - '.github/CODEOWNERS' + - '.github/pull_request_template.md' + - 'docs/**/*.css' + - 'docs/**/*.html' + - 'docs/**/*.ico' + - 'docs/**/*.md' + - 'docs/**/*.png' + - 'docs/**/*.svg' + - 'mkdocs.yml' + - 'README.md' + - 'RELEASING.md' + - '.sdkmanrc' + +concurrency: + group: "${{ github.workflow }}-${{ github.head_ref || github.sha }}" + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-22.04 + permissions: + checks: write + steps: + - uses: actions/checkout@v6 + - name: Setup rootless Docker + uses: docker/setup-docker-action@v5 + with: + rootless: true + - uses: ./.github/actions/setup-build + - name: Build with Gradle + run: ./gradlew --no-daemon --scan testcontainers:test --tests '*GenericContainerRuleTest' + - uses: ./.github/actions/setup-junit-report diff --git a/.github/workflows/ci-windows-trigger.yml b/.github/workflows/ci-windows-trigger.yml new file mode 100644 index 00000000000..ce5d40a173b --- /dev/null +++ b/.github/workflows/ci-windows-trigger.yml @@ -0,0 +1,27 @@ +name: windows-test command dispatch + +on: + issue_comment: + types: [created] + +permissions: + contents: read + +jobs: + windows-test-command-trigger: + permissions: + pull-requests: write # for peter-evans/slash-command-dispatch to create PR reaction + runs-on: ubuntu-latest + + steps: + - name: Trigger windows-test command + uses: peter-evans/slash-command-dispatch@9bdcd7914ec1b75590b790b844aa3b8eee7c683a # v5.0.2 + with: + token: ${{ secrets.WINDOWS_WORKERS_TOKEN }} + # The command to trigger the pipeline: e.g. /windows-test + # The command name must match the name of the repository_dispatch.type in 'ci-windows.yml' workflow, using '-command' as suffix. E.g. 'windows-test-command' + commands: windows-test + issue-type: pull-request + # The user that owns the above token must belong to the elevated role of 'Maintainers' + permission: maintain + reactions: false diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml new file mode 100644 index 00000000000..dbc1e78aa32 --- /dev/null +++ b/.github/workflows/ci-windows.yml @@ -0,0 +1,119 @@ +name: CI - Windows + +on: + pull_request: + paths-ignore: + - '.github/ISSUE_TEMPLATE/*.yaml' + - '.github/CODEOWNERS' + - '.github/pull_request_template.md' + - 'docs/**/*.css' + - 'docs/**/*.html' + - 'docs/**/*.ico' + - 'docs/**/*.md' + - 'docs/**/*.png' + - 'docs/**/*.svg' + - 'mkdocs.yml' + - 'README.md' + - 'RELEASING.md' + - '.sdkmanrc' + push: + branches: [ main ] + paths-ignore: + - '.github/ISSUE_TEMPLATE/*.yaml' + - '.github/CODEOWNERS' + - '.github/pull_request_template.md' + - 'docs/**/*.css' + - 'docs/**/*.html' + - 'docs/**/*.ico' + - 'docs/**/*.md' + - 'docs/**/*.png' + - 'docs/**/*.svg' + - 'mkdocs.yml' + - 'README.md' + - 'RELEASING.md' + - '.sdkmanrc' + repository_dispatch: + types: [windows-test-command] + +concurrency: + group: "${{ github.workflow }}-${{ github.head_ref || github.sha }}" + cancel-in-progress: true + +permissions: + contents: read + +env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} + +jobs: + main: + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + runs-on: self-hosted + permissions: + checks: write + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-build + - name: Build with Gradle + run: ./gradlew.bat cleanTest testcontainers:test --no-daemon --continue --scan --no-build-cache + - uses: ./.github/actions/setup-junit-report + + pr: + if: ${{ github.event.client_payload.slash_command.command == 'windows-test' }} + runs-on: self-hosted + permissions: + checks: write + statuses: write + steps: + - name: Create pending status + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.payload.client_payload.pull_request.head.sha, + state: 'pending', + target_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`, + context: 'CI - Windows', + }) + - uses: actions/checkout@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.event.client_payload.pull_request.head.repo.full_name }} + ref: ${{ github.event.client_payload.pull_request.head.ref }} + - uses: ./.github/actions/setup-build + - name: Build with Gradle + run: ./gradlew.bat cleanTest testcontainers:test --no-daemon --continue --scan --no-build-cache + - uses: ./.github/actions/setup-junit-report + + - name: Create success status + uses: actions/github-script@v9 + if: success() + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.payload.client_payload.pull_request.head.sha, + state: 'success', + target_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`, + context: 'CI - Windows', + }) + + - name: Create failure status + uses: actions/github-script@v9 + if: failure() + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.payload.client_payload.pull_request.head.sha, + state: 'failure', + target_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`, + context: 'CI - Windows', + }) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000000..62dd2acc4ef --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,186 @@ +name: CI + +on: + pull_request: + paths-ignore: + - '.github/ISSUE_TEMPLATE/*.yaml' + - '.github/CODEOWNERS' + - '.github/pull_request_template.md' + - 'docs/**/*.css' + - 'docs/**/*.html' + - 'docs/**/*.ico' + - 'docs/**/*.md' + - 'docs/**/*.png' + - 'docs/**/*.svg' + - 'mkdocs.yml' + - 'README.md' + - 'RELEASING.md' + - '.sdkmanrc' + push: + branches: [ main ] + paths-ignore: + - '.github/ISSUE_TEMPLATE/*.yaml' + - '.github/CODEOWNERS' + - '.github/pull_request_template.md' + - 'docs/**/*.css' + - 'docs/**/*.html' + - 'docs/**/*.ico' + - 'docs/**/*.md' + - 'docs/**/*.png' + - 'docs/**/*.svg' + - 'mkdocs.yml' + - 'README.md' + - 'RELEASING.md' + - '.sdkmanrc' + +concurrency: + group: "${{ github.workflow }}-${{ github.head_ref || github.sha }}" + cancel-in-progress: true + +permissions: + contents: read + +env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} + +jobs: + core: + runs-on: ubuntu-22.04 + permissions: + checks: write + strategy: + matrix: + java: [ '17', '21' ] + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-build + with: + java-version: ${{ matrix.java }} + - name: Build and test with Gradle + run: | + ./gradlew :testcontainers:check --no-daemon --continue --scan + - uses: ./.github/actions/setup-junit-report + + turbo-mode: + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + runs-on: ubuntu-22.04 + permissions: + checks: write + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-build + - name: Setup Testcontainers Cloud Client + uses: atomicjar/testcontainers-cloud-setup-action@main + with: + token: ${{ secrets.TC_CLOUD_TOKEN }} + args: --max-concurrency=4 + - name: Test using Testcontainers Cloud with Turbo Mode enabled + working-directory: ./smoke-test/ + run: | + ./gradlew check --no-daemon --continue --scan --info + - uses: ./.github/actions/setup-junit-report + + find_gradle_jobs: + needs: [core] + runs-on: ubuntu-22.04 + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-build + - id: set-matrix + env: + # Since we override the tests executor, + # we should not push empty results to the cache + READ_ONLY_REMOTE_GRADLE_CACHE: true + run: | + set -o pipefail + TASKS=$(./gradlew --no-daemon --parallel -q testMatrix | jq 'del(.[] | select(. == ":testcontainers:check" or startswith(":docs:")))' --compact-output) + echo $TASKS + echo "matrix={\"gradle_args\":$TASKS}" >> $GITHUB_OUTPUT + check: + needs: [find_gradle_jobs] + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.find_gradle_jobs.outputs.matrix) }} + runs-on: ubuntu-22.04 + permissions: + checks: write + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-build + - name: Build and test with Gradle (${{matrix.gradle_args}}) + run: | + ./gradlew --no-daemon --continue --scan ${{matrix.gradle_args}} + - uses: ./.github/actions/setup-junit-report + + find_examples_jobs: + needs: [check] + runs-on: ubuntu-22.04 + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-build + - id: set-matrix + working-directory: ./examples/ + env: + # Since we override the tests executor, + # we should not push empty results to the cache + READ_ONLY_REMOTE_GRADLE_CACHE: true + run: | + TASKS=$(./gradlew --no-daemon --parallel -q testMatrix) + echo $TASKS + echo "matrix={\"gradle_args\":$TASKS}" >> $GITHUB_OUTPUT + check_examples: + needs: [find_examples_jobs] + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.find_examples_jobs.outputs.matrix) }} + runs-on: ubuntu-22.04 + permissions: + checks: write + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-build + - name: Build and test Examples with Gradle (${{matrix.gradle_args}}) + working-directory: ./examples/ + run: | + ./gradlew --no-daemon --continue --scan --info ${{matrix.gradle_args}} + - uses: ./.github/actions/setup-junit-report + + find_docs_examples_jobs: + needs: [check_examples] + runs-on: ubuntu-22.04 + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-build + - id: set-matrix + env: + # Since we override the tests executor, + # we should not push empty results to the cache + READ_ONLY_REMOTE_GRADLE_CACHE: true + run: | + set -o pipefail + TASKS=$(./gradlew --no-daemon --parallel -q testMatrix | jq 'map(select(startswith(":docs:")))' --compact-output) + echo $TASKS + echo "matrix={\"gradle_args\":$TASKS}" >> $GITHUB_OUTPUT + check_docs_examples: + needs: [find_docs_examples_jobs] + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.find_docs_examples_jobs.outputs.matrix) }} + runs-on: ubuntu-22.04 + permissions: + checks: write + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-build + - name: Build and test with Gradle (${{matrix.gradle_args}}) + run: | + ./gradlew --no-daemon --continue --scan ${{matrix.gradle_args}} + - uses: ./.github/actions/setup-junit-report diff --git a/.github/workflows/combine-prs.yml b/.github/workflows/combine-prs.yml new file mode 100644 index 00000000000..ed43d6ceed0 --- /dev/null +++ b/.github/workflows/combine-prs.yml @@ -0,0 +1,18 @@ +name: Combine PRs + +on: + workflow_dispatch: + +jobs: + combine-prs: + permissions: + contents: write + pull-requests: write + checks: read + runs-on: ubuntu-latest + steps: + - name: combine-prs + id: combine-prs + uses: github/combine-prs@v5.2.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 00000000000..336f0c7e650 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,14 @@ +name: "Pull Request Labeler" +on: +- pull_request_target + +jobs: + triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v7 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/moby-latest.yml b/.github/workflows/moby-latest.yml new file mode 100644 index 00000000000..3c4cc949f11 --- /dev/null +++ b/.github/workflows/moby-latest.yml @@ -0,0 +1,64 @@ +name: Tests against recent Docker engine releases + +on: + workflow_dispatch: + inputs: + version: + description: 'Docker version' + required: false + default: 'latest' + type: string + schedule: + # nightly build, at 23:59 CEST + - cron: '59 23 * * *' + +env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} + +jobs: + test_docker: + strategy: + matrix: + include: + - { install-docker-type: "STABLE", version: "${{ inputs.version }}", channel: stable, rootless: false } + - { install-docker-type: "ROOTLESS", version: "${{ inputs.version }}", channel: stable, rootless: true } + - { install-docker-type: "ROOTFUL", version: edge, channel: test, rootless: false } + name: "Core tests using Docker ${{ matrix.install-docker-type }} (channel ${{ matrix.channel }})" + runs-on: ubuntu-22.04 + continue-on-error: true + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-build + + - name: Install Stable Docker + id: setup_docker + uses: docker/setup-docker-action@v5 + with: + version: ${{ matrix.version }} + channel: ${{ matrix.channel }} + rootless: ${{ matrix.rootless }} + + - name: Check Docker version + run: docker version + + - name: Build with Gradle + run: ./gradlew cleanTest --no-daemon --continue --scan -Dscan.tag.DOCKER_${{ matrix.install-docker-type }} testcontainers:test -Dorg.gradle.caching=false + env: + DOCKER_HOST: ${{steps.setup_docker.outputs.sock}} + - uses: ./.github/actions/setup-junit-report + + - name: Notify to Slack on failures + if: failure() + id: slack + uses: slackapi/slack-github-action@v3.0.1 + with: + payload: | + { + "tc_project": "testcontainers-java", + "tc_docker_install_type": "${{ matrix.install-docker-type }}", + "tc_github_action_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}", + "tc_github_action_status": "FAILED", + "tc_slack_channel_id": "${{ secrets.SLACK_DOCKER_LATEST_CHANNEL_ID }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DOCKER_LATEST_WEBHOOK }} diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 00000000000..863b8d979a9 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,23 @@ +name: Release Drafter + +on: + push: + branches: + - main + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: read + +jobs: + update_release_draft: + permissions: + contents: write # for release-drafter/release-drafter to create a github release + pull-requests: write # for release-drafter/release-drafter to add label to PR + if: github.repository == 'testcontainers/testcontainers-java' + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000000..78d644971f8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Release + +on: + release: + types: [published] + +env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-java + - name: Clear existing docker image cache + run: docker image prune -af + + - name: Setup Gradle Build Action + uses: gradle/actions/setup-gradle@v6 + + - name: Run Gradle Build + run: ./gradlew build --scan --no-daemon -i -x test + + - name: Run Gradle Publish + run: | + ./gradlew publish \ + -Pversion="${{github.event.release.tag_name}}" --scan --no-daemon -i + + - name: Run Gradle Deploy + run: | + ./gradlew jreleaserDeploy -Pversion="${{github.event.release.tag_name}}" --scan --no-daemon -i + env: + JRELEASER_GPG_PUBLIC_KEY: ${{ vars.GPG_PUBLIC_KEY }} + JRELEASER_GPG_SECRET_KEY: ${{ secrets.GPG_SIGNING_KEY }} + JRELEASER_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + JRELEASER_MAVENCENTRAL_USERNAME: ${{ secrets.JRELEASER_MAVENCENTRAL_USERNAME }} + JRELEASER_MAVENCENTRAL_PASSWORD: ${{ secrets.JRELEASER_MAVENCENTRAL_PASSWORD }} diff --git a/.github/workflows/scripts/check_ci_status.sh b/.github/workflows/scripts/check_ci_status.sh new file mode 100755 index 00000000000..29ef1d939a8 --- /dev/null +++ b/.github/workflows/scripts/check_ci_status.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -o errexit +set -o nounset +set -o pipefail +set -o xtrace + +if [ -z $1 ] ; then + echo "First parameter (commit SHA) is required!" && exit 1; +fi + +STATUS=$(curl -s https://api.github.com/repos/testcontainers/testcontainers-java/commits/$1/status | jq -r '.state') + +[ "$STATUS" == 'success' ] diff --git a/.github/workflows/update-docs-version.yml b/.github/workflows/update-docs-version.yml new file mode 100644 index 00000000000..beec8ed1e77 --- /dev/null +++ b/.github/workflows/update-docs-version.yml @@ -0,0 +1,33 @@ +name: Update docs version + +on: + release: + types: [ published ] + +permissions: + contents: read + +jobs: + build: + permissions: + contents: write # for peter-evans/create-pull-request to create branch + pull-requests: write # for peter-evans/create-pull-request to create a PR + if: github.repository == 'testcontainers/testcontainers-java' + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + with: + ref: main + - name: Update latest_version property in mkdocs.yml + run: | + sed -i "s/latest_version: .*/latest_version: ${GITHUB_REF_NAME}/g" mkdocs.yml + git diff + - name: Create Pull Request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v3.10.1 + with: + title: Update docs version to ${{ github.ref_name }} + body: | + Update docs version to ${{ github.ref_name }} + skip-checks: true + branch: update-docs-version + delete-branch: true diff --git a/.github/workflows/update-gradle-wrapper.yml b/.github/workflows/update-gradle-wrapper.yml new file mode 100644 index 00000000000..9f710f30ec5 --- /dev/null +++ b/.github/workflows/update-gradle-wrapper.yml @@ -0,0 +1,27 @@ +name: Update Gradle Wrapper + +on: + schedule: + - cron: "0 0 * * *" + +permissions: + contents: read + +jobs: + update-gradle-wrapper: + permissions: + contents: write # for gradle-update/update-gradle-wrapper-action + pull-requests: write # for gradle-update/update-gradle-wrapper-action + if: github.repository == 'testcontainers/testcontainers-java' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Update Gradle Wrapper + uses: gradle-update/update-gradle-wrapper-action@512b1875f3b6270828abfe77b247d5895a2da1e5 # v1.0.13 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + labels: dependencies + + - uses: gradle/actions/wrapper-validation@v6 diff --git a/.github/workflows/update-testcontainers-version.yml b/.github/workflows/update-testcontainers-version.yml new file mode 100644 index 00000000000..d7105e0803c --- /dev/null +++ b/.github/workflows/update-testcontainers-version.yml @@ -0,0 +1,32 @@ +name: Update testcontainers version + +on: + release: + types: [ published ] + +permissions: + contents: read + +jobs: + build: + permissions: + contents: write # for peter-evans/create-pull-request to create branch + pull-requests: write # for peter-evans/create-pull-request to create a PR + if: github.repository == 'testcontainers/testcontainers-java' + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + with: + ref: main + - name: Update testcontainers.version property in gradle.properties + run: | + sed -i "s/^testcontainers\.version=.*/testcontainers\.version=${GITHUB_REF_NAME}/g" gradle.properties + git diff + - name: Create Pull Request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v3.10.1 + with: + title: Update testcontainers version to ${{ github.ref_name }} + body: | + Update testcontainers version to ${{ github.ref_name }} + branch: update-tc-version + delete-branch: true diff --git a/.gitignore b/.gitignore index 698be93b05e..120181d6e1e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,8 @@ dependency-reduced-pom.xml *.iml ## Directory-based project format: -.idea/ +.idea/* +!.idea/icon.png # if you remove the above rule, at least ignore the following: # User-specific stuff: @@ -43,3 +44,30 @@ dependency-reduced-pom.xml # Gitbook _book/ node_modules/ + +.gradle/ +build/ +!buildSrc/src/main/groovy/org/testcontainers/build +out/ +*.class + +# Eclipse IDE files +**/.project +**/.classpath +**/.settings +**/bin/ +**/out/ + +# Generated docs +site/ +.direnv/ +src/mkdocs-codeinclude-plugin +src/pip-delete-this-directory.txt + +.DS_Store + +# Codespaces / VSCode +/.vscode/ + +# Python virtual environment +/.venv/ diff --git a/.idea/icon.png b/.idea/icon.png new file mode 100644 index 00000000000..0f1d8ff4434 Binary files /dev/null and b/.idea/icon.png differ diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar deleted file mode 100644 index c6feb8bb6f7..00000000000 Binary files a/.mvn/wrapper/maven-wrapper.jar and /dev/null differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties deleted file mode 100644 index 6637cedb28e..00000000000 --- a/.mvn/wrapper/maven-wrapper.properties +++ /dev/null @@ -1 +0,0 @@ -distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.3.9/apache-maven-3.3.9-bin.zip \ No newline at end of file diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 00000000000..1c5feb60c95 --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1,3 @@ +# Enable auto-env through the sdkman_auto_env config +# Add key=value pairs of SDKs to use below +java=17.0.16-tem diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f357a00287c..00000000000 --- a/.travis.yml +++ /dev/null @@ -1,57 +0,0 @@ -language: java -jdk: - - oraclejdk8 -sudo: required -services: - - docker - -before_install: - - sudo apt-get update -qq - - sudo apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confnew install docker-engine - - env -# Pre-fetch docker images - seems to need to be done before docker env vars are set - - docker pull redis:3.0.2 - - docker pull rabbitmq:3.5.3 - - docker pull mongo:3.1.5 - - docker pull alpine:3.2 - - docker pull mysql:latest - - docker pull mysql:5.5.43 - - docker pull mysql:5.6 - - docker pull mysql:5.5 - - docker pull postgres:latest - - docker pull selenium/standalone-chrome-debug:2.52.0 - - docker pull selenium/standalone-firefox-debug:2.52.0 - - docker pull richnorth/vnc-recorder:latest - - docker pull nginx:1.9.4 - - docker pull dduportal/docker-compose:1.6.0 - - docker pull richnorth/ambassador:latest -# Update env vars required by testcontainers - - export DOCKER_HOST=tcp://127.0.0.1:2375 - - export DOCKER_TLS_VERIFY=0 - - env - -script: - - mvn -B test - # Run Docker-in-Docker tests - - | - DOCKER_HOST=unix:///var/run/docker.sock DOCKER_TLS_VERIFY= docker run --rm \ - -v "$HOME/.m2":/root/.m2/ \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -v "$(pwd)":"$(pwd)" \ - -w "$(pwd)" \ - openjdk:8-jre \ - ./mvnw -B -pl core test -Dtest=*GenericContainerRuleTest - # Run Docker-in-Docker tests inside Alpine - - | - DOCKER_HOST=unix:///var/run/docker.sock DOCKER_TLS_VERIFY= docker run --rm \ - -v "$HOME/.m2":/root/.m2/ \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -v "$(pwd)":"$(pwd)" \ - -w "$(pwd)" \ - kiview/openjdk-alpine-bash:8u111 \ - ./mvnw -B -pl core test -Dtest=*GenericContainerRuleTest - - mvn -B test -f shade-test/pom.xml - -cache: - directories: - - '$HOME/.m2/repository' diff --git a/AUTHORS b/AUTHORS index f67f444bf02..b5423dc9519 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,11 +1,3 @@ -Richard North -Gurpreet Sohal -Alex Boldt -Robert Požarickij -Matthieu Baechler -Krystian Nowak -Viktor Schulz -Asaf Mesika -Sergei Egorov -Pete Cornish -Miguel Gonzalez Sanchez +This file is deprecated. Please see the [contributors](https://github.com/testcontainers/testcontainers-java/graphs/contributors) listing. + +[Richard North](https://github.com/rnorth), [Sergei Egorov](https://github.com/bsideup) and [Kevin Wittek](https://github.com/kiview) are the core maintainers. diff --git a/CHANGELOG.md b/CHANGELOG.md index d32dfbcb781..cef3d65e346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,90 +1,346 @@ # Change Log -All notable changes to this project will be documented in this file. +~All notable changes to this project will be documented in this file.~ + +# MOVED + +**After version 1.8.3 all future releases will _only_ be documented in the [Releases](https://github.com/testcontainers/testcontainers-java/releases) section of the GitHub repository. This changelog file will eventually be removed.** + + +## [1.8.3] - 2018-08-05 + +### Fixed + +- Fixed `with*` methods of `CouchbaseContainer` ([\#810](https://github.com/testcontainers/testcontainers-java/pull/810)) +- Fix problem with gzip encoded streams (e.g. copy file from container), by adding decompression support to netty exec factory (#817, fixes #681, relates to docker-java/docker-java#1079) + +## [1.8.2] - 2018-07-31 + +### Fixed + +- Add support for transparently using local images with docker-compose ([\#798](https://github.com/testcontainers/testcontainers-java/pull/798), fixes [\#674](https://github.com/testcontainers/testcontainers-java/issues/674)) +- Fix bug with Dockerfile image creation with Docker for Mac 18.06-ce ([\#808](https://github.com/testcontainers/testcontainers-java/pull/808), fixes [\#680](https://github.com/testcontainers/testcontainers-java/issues/680)) + +### Changed + +- Update Visible Assertions to 2.1.1 ([\#779](https://github.com/testcontainers/testcontainers-java/pull/779)). +- KafkaContainer optimization (`group.initial.rebalance.delay.ms=0`) ([\#782](https://github.com/testcontainers/testcontainers-java/pull/782)). + +## [1.8.1] - 2018-07-10 + +### Fixed +- Linux/Mac: Added support for docker credential helpers so that images may be pulled from private registries. See [\#729](https://github.com/testcontainers/testcontainers-java/issues/729), [\#647](https://github.com/testcontainers/testcontainers-java/issues/647) and [\#567](https://github.com/testcontainers/testcontainers-java/issues/567). +- Ensure that the `COMPOSE_FILE` environment variable is populated with all relevant compose file names when running docker-compose in local mode [\#755](https://github.com/testcontainers/testcontainers-java/issues/755). +- Fixed issue whereby specified command in MariaDB image was not being applied. ([\#534](https://github.com/testcontainers/testcontainers-java/issues/534)) +- Changed Oracle thin URL to support both Oracle 11 and 12 XE ([\#769](https://github.com/testcontainers/testcontainers-java/issues/769)) +- Ensure that full JDBC URL query string is passed to JdbcDatabaseDelegate during initscript invocation ([\#741](https://github.com/testcontainers/testcontainers-java/issues/741); fixes [\#727](https://github.com/testcontainers/testcontainers-java/issues/727)) +- Ensure that necessary transitive dependency inclusions are applied to generated project POMs ([\#772](https://github.com/testcontainers/testcontainers-java/issues/772); fixes [\#753](https://github.com/testcontainers/testcontainers-java/issues/753) and [\#652](https://github.com/testcontainers/testcontainers-java/issues/652)) + +### Changed +- Update Apache Pulsar module to 2.0.1 [\#760](https://github.com/testcontainers/testcontainers-java/issues/760). +- Make JdbcDatabaseContainer#getDriverClassName public [\#743](https://github.com/testcontainers/testcontainers-java/pull/743). +- enable `copyFileToContainer` feature during container startup [\#742](https://github.com/testcontainers/testcontainers-java/pull/742). +- avoid using file mounting in KafkaContainer [\#775](https://github.com/testcontainers/testcontainers-java/pull/775). +- Added Apache Cassandra module [\#776](https://github.com/testcontainers/testcontainers-java/pull/776). + +## [1.8.0] - 2018-06-14 + +### Fixed +- Fixed JDBC URL Regex Pattern to ensure all supported Database URL's are accepted ([\#596](https://github.com/testcontainers/testcontainers-java/issues/596)) +- Filtered out TestContainer parameters (TC_*) from query string before passing to database ([\#345](https://github.com/testcontainers/testcontainers-java/issues/345)) +- Use `latest` tag as default image tag ([\#676](https://github.com/testcontainers/testcontainers-java/issues/676)) + +### Changed +- Allow `HttpWaitStrategy` to wait for a specific port ([\#703](https://github.com/testcontainers/testcontainers-java/pull/703)) +- New module: Apache Pulsar ([\#713](https://github.com/testcontainers/testcontainers-java/pull/713)) +- Add support for defining container labels ([\#725](https://github.com/testcontainers/testcontainers-java/pull/725)) +- Use `quay.io/testcontainers/ryuk` instead of `bsideup/ryuk` ([\#721](https://github.com/testcontainers/testcontainers-java/pull/721)) +- Added Couchbase module ([\#688](https://github.com/testcontainers/testcontainers-java/pull/688)) +- Enhancements and Fixes for JDBC URL usage to create Containers ([\#594](https://github.com/testcontainers/testcontainers-java/pull/594)) + - Extracted JDBC URL manipulations to a separate class - `ConnectionUrl`. + - Added an overloaded method `JdbcDatabaseContainerProvider.newInstance(ConnectionUrl)`, with default implementation delegating to the existing `newInstance(tag)` method. (Relates to [\#566](https://github.com/testcontainers/testcontainers-java/issues/566)) + - Added an implementation of `MySQLContainerProvider.newInstance(ConnectionUrl)` that uses Database Name, User, and Password from JDBC URL while creating new MySQL Container. ([\#566](https://github.com/testcontainers/testcontainers-java/issues/566) for MySQL Container) +- Changed **internal** port of KafkaContainer back to 9092 ([\#733](https://github.com/testcontainers/testcontainers-java/pull/733)) +- Add support for Dockerfile based images to OracleContainer ([\#734](https://github.com/testcontainers/testcontainers-java/pull/734)) +- Read from both `/proc/net/tcp` and `/proc/net/tcp6` in `InternalCommandPortListeningCheck` ([\#750](https://github.com/testcontainers/testcontainers-java/pull/750)) +- Added builder methods for timeouts in `JdbcDatabaseContainer` ([\#748](https://github.com/testcontainers/testcontainers-java/pull/748)) +- Added an alternative experimental transport based on OkHttp. Enable it with `transport.type=okhttp` property ([\#710](https://github.com/testcontainers/testcontainers-java/pull/710)) +- Framework-agnostic container & test lifecycle ([\#702](https://github.com/testcontainers/testcontainers-java/pull/702)) + +## [1.7.3] - 2018-05-16 + +### Fixed +- Fix for setting `ryuk.container.timeout` causes a `ClassCastException` ([\#684](https://github.com/testcontainers/testcontainers-java/issues/684)) +- Fixed provided but shaded dependencies in modules ([\#693](https://github.com/testcontainers/testcontainers-java/issues/693)) + +### Changed +- Added InfluxDB module ([\#686](https://github.com/testcontainers/testcontainers-java/pull/686)) +- Added MockServer module ([\#696](https://github.com/testcontainers/testcontainers-java/pull/696)) +- Changed LocalStackContainer to extend GenericContainer ([\#695](https://github.com/testcontainers/testcontainers-java/pull/695)) + +## [1.7.2] - 2018-04-30 + +- Add support for private repositories using docker credential stores/helpers (fixes [\#567](https://github.com/testcontainers/testcontainers-java/issues/567)) + +### Fixed +- Add support for private repositories using docker credential stores/helpers (fixes [\#567](https://github.com/testcontainers/testcontainers-java/issues/567)) +- Retry any exceptions (not just `DockerClientException`) on image pull ([\#662](https://github.com/testcontainers/testcontainers-java/issues/662)) +- Fixed handling of the paths with `+` in them ([\#664](https://github.com/testcontainers/testcontainers-java/issues/664)) + +### Changed +- Database container images are now pinned to a specific version rather than using `latest`. The tags selected are the most recent as of the time of this change. If a JDBC URL is used with no tag specified, a WARN level log message is output, pending a future change to make tags mandatory in the JDBC URL. ([\#671](https://github.com/testcontainers/testcontainers-java/issues/671)) +- Updated docker-java to 3.1.0-rc-3, enforced `org.jetbrains:annotations:15.0`. ([\#672](https://github.com/testcontainers/testcontainers-java/issues/672)) + +## [1.7.1] - 2018-04-20 + +### Fixed +- Fixed missing `commons-codec` dependency ([\#642](https://github.com/testcontainers/testcontainers-java/issues/642)) +- Fixed `HostPortWaitStrategy` throws `NumberFormatException` when port is exposed but not mapped ([\#640](https://github.com/testcontainers/testcontainers-java/issues/640)) +- Fixed log processing: multibyte unicode, linebreaks and ASCII color codes. Color codes can be turned on with `withRemoveAnsiCodes(false)` ([\#643](https://github.com/testcontainers/testcontainers-java/pull/643)) +- Fixed Docker host IP detection within docker container (detect only if not explicitly set) ([\#648](https://github.com/testcontainers/testcontainers-java/pull/648)) +- Add support for private repositories using docker credential stores/helpers ([PR \#647](https://github.com/testcontainers/testcontainers-java/pull/647), fixes [\#567](https://github.com/testcontainers/testcontainers-java/issues/567)) + +### Changed +- Support multiple HTTP status codes for HttpWaitStrategy ([\#630](https://github.com/testcontainers/testcontainers-java/issues/630)) +- Mark all long-living threads started by Testcontainers as daemons and group them. ([\#646](https://github.com/testcontainers/testcontainers-java/issues/646)) +- Remove noisy `DEBUG` logging of Netty packets ([\#646](https://github.com/testcontainers/testcontainers-java/issues/646)) +- Updated docker-java to 3.1.0-rc-2 ([\#646](https://github.com/testcontainers/testcontainers-java/issues/646)) + +## [1.7.0] - 2018-04-07 + +### Fixed +- Fixed extraneous insertion of `useSSL=false` in all JDBC URL strings, even for DBs that do not understand it. Usage is now restricted to MySQL by default and can be overridden by authors of `JdbcDatabaseContainer` subclasses ([\#568](https://github.com/testcontainers/testcontainers-java/issues/568)) +- Fixed `getServicePort` on `DockerComposeContainer` throws NullPointerException if service instance number in not used. ([\#619](https://github.com/testcontainers/testcontainers-java/issues/619)) +- Increase Ryuk's timeout and make it configurable with `ryuk.container.timeout`. ([\#621](https://github.com/testcontainers/testcontainers-java/issues/621)[\#635](https://github.com/testcontainers/testcontainers-java/issues/635)) + +### Changed +- Added compatibility with selenium greater than 3.X ([\#611](https://github.com/testcontainers/testcontainers-java/issues/611)) +- Abstracted and changed database init script functionality to support use of SQL-like scripts with non-JDBC connections. ([\#551](https://github.com/testcontainers/testcontainers-java/pull/551)) +- Added `JdbcDatabaseContainer(Future)` constructor. ([\#543](https://github.com/testcontainers/testcontainers-java/issues/543)) +- Mark DockerMachineClientProviderStrategy as not persistable ([\#593](https://github.com/testcontainers/testcontainers-java/pull/593)) +- Added `waitingFor(String serviceName, WaitStrategy waitStrategy)` and overloaded `withExposedService()` methods to `DockerComposeContainer` to allow user to define `WaitStrategy` for compose containers. ([\#174](https://github.com/testcontainers/testcontainers-java/issues/174), [\#515](https://github.com/testcontainers/testcontainers-java/issues/515) and ([\#600](https://github.com/testcontainers/testcontainers-java/pull/600))) +- Deprecated `WaitStrategy` and implementations in favour of classes with same names in `org.testcontainers.containers.strategy` ([\#600](https://github.com/testcontainers/testcontainers-java/pull/600)) +- Added `ContainerState` interface representing the state of a started container ([\#600](https://github.com/testcontainers/testcontainers-java/pull/600)) +- Added `WaitStrategyTarget` interface which is the target of the new `WaitStrategy` ([\#600](https://github.com/testcontainers/testcontainers-java/pull/600)) +- *Breaking:* Removed hard-coded `wnameless` Oracle database image name. Users should instead place a file on the classpath named `testcontainers.properties` containing `oracle.container.image=IMAGE`, where IMAGE is a suitable image name and tag/SHA hash. For information, the approach recommended by Oracle for creating an Oracle XE docker image is described [here](https://blogs.oracle.com/oraclewebcentersuite/implement-oracle-database-xe-as-docker-containers). +- Added `DockerHealthcheckWaitStrategy` that is based on Docker's built-in [healthcheck](https://docs.docker.com/engine/reference/builder/#healthcheck) ([\#618](https://github.com/testcontainers/testcontainers-java/pull/618)). +- Added `withLogConsumer(String serviceName, Consumer consumer)` method to `DockerComposeContainer` ([\#605](https://github.com/testcontainers/testcontainers-java/issues/605)) +- Added `withFixedExposedPort(int hostPort, int containerPort, InternetProtocol protocol)` method to `FixedHostPortGenericContainer` and `addFixedExposedPort(int hostPort, int containerPort, InternetProtocol protocol)` to `GenericContainer` ([\#586](https://github.com/testcontainers/testcontainers-java/pull/586)) + +## [1.6.0] - 2018-01-28 + +### Fixed +- Fixed incompatibility of Docker-Compose container with JDK9. ([\#562](https://github.com/testcontainers/testcontainers-java/pull/562)) +- Fixed retrieval of Docker host IP when running inside Docker. ([\#479](https://github.com/testcontainers/testcontainers-java/issues/479)) +- Compose is now able to pull images from private repositories. ([\#536](https://github.com/testcontainers/testcontainers-java/issues/536)) +- Fixed overriding MySQL image command. ([\#534](https://github.com/testcontainers/testcontainers-java/issues/534)) +- Fixed shading for javax.annotation.CheckForNull ([\#563](https://github.com/testcontainers/testcontainers-java/issues/563) and [testcontainers/testcontainers-scala\#11](https://github.com/testcontainers/testcontainers-scala/issues/11)). + +### Changed +- Added JDK9 build and tests to Travis-CI. ([\#562](https://github.com/testcontainers/testcontainers-java/pull/562)) +- Added Kafka module ([\#546](https://github.com/testcontainers/testcontainers-java/pull/546)) +- Added "Death Note" to track & kill spawned containers even if the JVM was "kill -9"ed ([\#545](https://github.com/testcontainers/testcontainers-java/pull/545)) +- Environment variables are now stored as Map instead of List ([\#550](https://github.com/testcontainers/testcontainers-java/pull/550)) +- Added `withEnv(String name, Function, String> mapper)` with optional previous value ([\#550](https://github.com/testcontainers/testcontainers-java/pull/550)) +- Added `withFileSystemBind` overloaded method with `READ_WRITE` file mode by default ([\#550](https://github.com/testcontainers/testcontainers-java/pull/550)) +- All connections to JDBC containers (e.g. MySQL) don't use SSL anymore. ([\#374](https://github.com/testcontainers/testcontainers-java/issues/374)) + +## [1.5.1] - 2017-12-19 + +### Fixed +- Fixed problem with case-sensitivity when checking internal port. ([\#524](https://github.com/testcontainers/testcontainers-java/pull/524)) +- Add retry logic around checkExposedPort pre-flight check for improved robustness ([\#513](https://github.com/testcontainers/testcontainers-java/issues/513)) + +### Changed +- Added `getDatabaseName` method to JdbcDatabaseContainer, MySQLContainer, PostgreSQLContainer ([\#473](https://github.com/testcontainers/testcontainers-java/issues/473)) +- Added `VncRecordingContainer` - Network-based, attachable re-implementation of `VncRecordingSidekickContainer` ([\#526](https://github.com/testcontainers/testcontainers-java/pull/526)) + +## [1.5.0] - 2017-12-12 +### Fixed +- Fixed problems with using container based docker-compose on Windows ([\#514](https://github.com/testcontainers/testcontainers-java/pull/514)) +- Fixed problems with copying files on Windows ([\#514](https://github.com/testcontainers/testcontainers-java/pull/514)) +- Fixed regression in 1.4.3 when using Docker Compose on Windows ([\#439](https://github.com/testcontainers/testcontainers-java/issues/439)) +- Fixed local Docker Compose executable name resolution on Windows ([\#416](https://github.com/testcontainers/testcontainers-java/issues/416)) +- Fixed TAR composition on Windows ([\#444](https://github.com/testcontainers/testcontainers-java/issues/444)) +- Allowing `addExposedPort` to be used after ports have been specified with `withExposedPorts` ([\#453](https://github.com/testcontainers/testcontainers-java/issues/453)) +- Stopping creation of temporary directory prior to creating temporary file ([\#443](https://github.com/testcontainers/testcontainers-java/issues/443)) +- Ensure that temp files are created in a temp directory ([\#423](https://github.com/testcontainers/testcontainers-java/issues/423)) +- Added `WaitAllStrategy` as a mechanism for composing multiple startup `WaitStrategy` objects together +- Changed `BrowserWebDriverContainer` to use improved wait strategies, to eliminate race conditions when starting VNC recording containers. This should lead to far fewer 'error' messages logged when starting up selenium containers, and less exposure to race related bugs (fixes [\#466](https://github.com/testcontainers/testcontainers-java/issues/466)). + +### Changed +- Make Network instances reusable (i.e. work with `@ClassRule`) ([\#469](https://github.com/testcontainers/testcontainers-java/issues/469)) +- Added support for explicitly setting file mode when copying file into container ([\#446](https://github.com/testcontainers/testcontainers-java/issues/446), [\#467](https://github.com/testcontainers/testcontainers-java/issues/467)) +- Use Visible Assertions 2.1.0 for pre-flight test output (eliminating Jansi/JNR-POSIX dependencies for lower likelihood of conflict. JNA is now used internally by Visible Assertions instead). +- Mark all links functionality as deprecated. This is pending removal in a later release. Please see [\#465](https://github.com/testcontainers/testcontainers-java/issues/465). `Network` features should be used instead. +- Added support for copying files to/from running containers ([\#378](https://github.com/testcontainers/testcontainers-java/issues/378)) +- Add `getLivenessCheckPorts` as an eventual replacement for `getLivenessCheckPort`; this allows multiple ports to be included in post-startup wait strategies. +- Refactor wait strategy port checking and improve test coverage. +- Added support for customising the recording file name ([\#500](https://github.com/testcontainers/testcontainers-java/issues/500)) + +## [1.4.3] - 2017-10-14 +### Fixed +- Fixed local Docker Compose executable name resolution on Windows ([\#416](https://github.com/testcontainers/testcontainers-java/issues/416), [\#460](https://github.com/testcontainers/testcontainers-java/issues/460)) +- Fixed TAR composition on Windows ([\#444](https://github.com/testcontainers/testcontainers-java/issues/444)) +- Allowing `addExposedPort` to be used after ports have been specified with `withExposedPorts` ([\#453](https://github.com/testcontainers/testcontainers-java/issues/453)) +- Stopping creation of temporary directory prior to creating temporary file ([\#443](https://github.com/testcontainers/testcontainers-java/issues/443)) + +### Changed +- Added `forResponsePredicate` method to HttpWaitStrategy to test response body ([\#441](https://github.com/testcontainers/testcontainers-java/issues/441)) +- Changed `DockerClientProviderStrategy` to be loaded via Service Loader ([\#434](https://github.com/testcontainers/testcontainers-java/issues/434), [\#435](https://github.com/testcontainers/testcontainers-java/issues/435)) +- Made it possible to specify docker compose container in configuration ([\#422](https://github.com/testcontainers/testcontainers-java/issues/422), [\#425](https://github.com/testcontainers/testcontainers-java/issues/425)) +- Clarified wording of pre-flight check messages ([\#457](https://github.com/testcontainers/testcontainers-java/issues/457), [\#436](https://github.com/testcontainers/testcontainers-java/issues/436)) +- Added caching of failure to find a docker daemon, so that subsequent tests fail fast. This is likely to be a significant improvement in situations where there is no docker daemon available, dramatically reducing run time and log output when further attempts to find the docker daemon cannot succeed. +- Allowing JDBC containers' username, password and DB name to be customized ([\#400](https://github.com/testcontainers/testcontainers-java/issues/400), [\#354](https://github.com/testcontainers/testcontainers-java/issues/354)) + +## [1.4.2] - 2017-07-25 +### Fixed +- Worked around incompatibility between Netty's Unix socket support and OS X 10.11. Reinstated use of TCP-Unix Socket proxy when running on OS X prior to v10.12. (Fixes [\#402](https://github.com/testcontainers/testcontainers-java/issues/402)) +- Changed to use version 2.0 of the Visible Assertions library for startup pre-flight checks. This no longer has a dependency on Jansi, and is intended to resolve a JVM crash issue apparently caused by native lib version conflicts ([\#395](https://github.com/testcontainers/testcontainers-java/issues/395)). Please note that the newer ANSI code is less mature and thus has had less testing, particularly in interesting terminal environments such as Windows. If issues are encountered, coloured assertion output may be disabled by setting the system property `visibleassertions.ansi.enabled` to `true`. +- Fixed NullPointerException when calling GenericContainer#isRunning on not started container ([\#411](https://github.com/testcontainers/testcontainers-java/issues/411)) + +### Changed +- Removed Guava usage from `jdbc` module ([\#401](https://github.com/testcontainers/testcontainers-java/issues/401)) + +## [1.4.1] - 2017-07-10 +### Fixed +- Fixed Guava shading in `jdbc` module + +## [1.4.0] - 2017-07-09 +### Fixed +- Fixed the case when disk's size is bigger than Integer's max value ([\#379](https://github.com/testcontainers/testcontainers-java/issues/379), [\#380](https://github.com/testcontainers/testcontainers-java/issues/380)) +- Fixed erroneous version reference used during CI testing of shaded dependencies +- Fixed leakage of Vibur and Tomcat JDBC test dependencies in `jdbc-test` and `mysql` modules ([\#382](https://github.com/testcontainers/testcontainers-java/issues/382)) +- Added timeout and retries for creation of `RemoteWebDriver` ([\#381](https://github.com/testcontainers/testcontainers-java/issues/381), [\#373](https://github.com/testcontainers/testcontainers-java/issues/373), [\#257](https://github.com/testcontainers/testcontainers-java/issues/257)) +- Fixed various shading issues +- Improved removal of containers/networks when using Docker Compose, eliminating irrelevant errors during cleanup ([\#342](https://github.com/testcontainers/testcontainers-java/issues/342), [\#394](https://github.com/testcontainers/testcontainers-java/issues/394)) + +### Changed +- Added support for Docker networks ([\#372](https://github.com/testcontainers/testcontainers-java/issues/372)) +- Added `getFirstMappedPort` method ([\#377](https://github.com/testcontainers/testcontainers-java/issues/377)) +- Extracted Oracle XE container into a separate repository ([testcontainers/testcontainers-java-module-oracle-xe](https://github.com/testcontainers/testcontainers-java-module-oracle-xe)) +- Added shading tests +- Updated docker-java to 3.0.12 ([\#393](https://github.com/testcontainers/testcontainers-java/issues/393)) + +## [1.3.1] - 2017-06-22 +### Fixed +- Fixed non-POSIX fallback for file attribute reading ([\#371](https://github.com/testcontainers/testcontainers-java/issues/371)) +- Fixed NullPointerException in AuditLogger when running using slf4j-log4j12 bridge ([\#375](https://github.com/testcontainers/testcontainers-java/issues/375)) +- Improved cleanup of JDBC connections during database container startup checks + +### Changed +- Extracted MariaDB into a separate repository ([\#337](https://github.com/testcontainers/testcontainers-java/issues/337)) +- Added `TC_DAEMON` JDBC URL flag to prevent `ContainerDatabaseDriver` from shutting down containers at the time all connections are closed. ([\#359](https://github.com/testcontainers/testcontainers-java/issues/359), [\#360](https://github.com/testcontainers/testcontainers-java/issues/360)) +- Added pre-flight checks (can be disabled with `checks.disable` configuration property) ([\#363](https://github.com/testcontainers/testcontainers-java/issues/363)) +- Improved startup time by adding dynamic priorities to DockerClientProviderStrategy ([\#362](https://github.com/testcontainers/testcontainers-java/issues/362)) +- Added global configuration file `~/.testcontainers.properties` ([\#362](https://github.com/testcontainers/testcontainers-java/issues/362)) +- Added container arguments to specify SELinux contexts for mounts ([\#334](https://github.com/testcontainers/testcontainers-java/issues/334)) +- Removed unused Jersey dependencies ([\#361](https://github.com/testcontainers/testcontainers-java/issues/361)) +- Removed deprecated, wrongly-generated setters from `GenericContainer` + +## [1.3.0] - 2017-06-05 +### Fixed +- Improved container cleanup if startup failed ([\#336](https://github.com/testcontainers/testcontainers-java/issues/336), [\#335](https://github.com/testcontainers/testcontainers-java/issues/335)) + +### Changed +- Upgraded docker-java library to 3.0.10 ([\#349](https://github.com/testcontainers/testcontainers-java/issues/349)) +- Added basic audit logging of Testcontainers' actions via a specific SLF4J logger name with metadata captured via MDC. Intended for use in highly shared Docker environments. +- Use string-based detection of Selenium container startup ([\#328](https://github.com/testcontainers/testcontainers-java/issues/328), [\#351](https://github.com/testcontainers/testcontainers-java/issues/351)) +- Use string-based detection of PostgreSQL container startup ([\#327](https://github.com/testcontainers/testcontainers-java/issues/327), [\#317](https://github.com/testcontainers/testcontainers-java/issues/317)) +- Update libraries to recent versions ([\#333](https://github.com/testcontainers/testcontainers-java/issues/333)) +- Introduce abstraction over files and classpath resources, allowing recursive copying of directories ([\#313](https://github.com/testcontainers/testcontainers-java/issues/313)) + +## [1.2.1] - 2017-04-06 +### Fixed +- Fix bug in space detection when `alpine:3.5` image has not yet been pulled ([\#323](https://github.com/testcontainers/testcontainers-java/issues/323), [\#324](https://github.com/testcontainers/testcontainers-java/issues/324)) +- Minor documentation fixes + +### Changed +- Add AOP Alliance dependencies to shaded deps to reduce chance of conflicts ([\#315](https://github.com/testcontainers/testcontainers-java/issues/315)) ## [1.2.0] - 2017-03-12 ### Fixed -- Fix various escaping issues that may arise when paths contain spaces (#263, #279) -- General documentation fixes/improvements (#300, #303, #304) -- Improve reliability of `ResourceReaper` when there are a large number of containers returned by `docker ps -a` (#295) +- Fix various escaping issues that may arise when paths contain spaces ([\#263](https://github.com/testcontainers/testcontainers-java/issues/263), [\#279](https://github.com/testcontainers/testcontainers-java/issues/279)) +- General documentation fixes/improvements ([\#300](https://github.com/testcontainers/testcontainers-java/issues/300), [\#303](https://github.com/testcontainers/testcontainers-java/issues/303), [\#304](https://github.com/testcontainers/testcontainers-java/issues/304)) +- Improve reliability of `ResourceReaper` when there are a large number of containers returned by `docker ps -a` ([\#295](https://github.com/testcontainers/testcontainers-java/issues/295)) ### Changed -- Support Docker for Windows via TCP socket connection (#291, #297, #309). _Note that Docker Compose is not yet supported under Docker for Windows (see #306) -- Expose `docker-java`'s `CreateContainerCmd` API for low-level container tweaking (#301) -- Shade `org.newsclub` and Guava dependencies (#299, #292) -- Add `org.testcontainers` label to all containers created by Testcontainers (#294) +- Support Docker for Windows via TCP socket connection ([\#291](https://github.com/testcontainers/testcontainers-java/issues/291), [\#297](https://github.com/testcontainers/testcontainers-java/issues/297), [\#309](https://github.com/testcontainers/testcontainers-java/issues/309)). _Note that Docker Compose is not yet supported under Docker for Windows (see [\#306](https://github.com/testcontainers/testcontainers-java/issues/306)) +- Expose `docker-java`'s `CreateContainerCmd` API for low-level container tweaking ([\#301](https://github.com/testcontainers/testcontainers-java/issues/301)) +- Shade `org.newsclub` and Guava dependencies ([\#299](https://github.com/testcontainers/testcontainers-java/issues/299), [\#292](https://github.com/testcontainers/testcontainers-java/issues/292)) +- Add `org.testcontainers` label to all containers created by Testcontainers ([\#294](https://github.com/testcontainers/testcontainers-java/issues/294)) ## [1.1.9] - 2017-02-12 ### Fixed -- Fix inability to run Testcontainers on Alpine linux. Unix-socket-over-TCP is now used in linux environments where netty fails due to lack of glibc libraries (#290) -- Fix slow feedback in the case of missing JDBC drivers by failing-fast if the required driver cannot be found (#280, #230) +- Fix inability to run Testcontainers on Alpine linux. Unix-socket-over-TCP is now used in linux environments where netty fails due to lack of glibc libraries ([\#290](https://github.com/testcontainers/testcontainers-java/issues/290)) +- Fix slow feedback in the case of missing JDBC drivers by failing-fast if the required driver cannot be found ([\#280](https://github.com/testcontainers/testcontainers-java/issues/280), [\#230](https://github.com/testcontainers/testcontainers-java/issues/230)) ### Changed -- Add ability to change 'tiny image' used for disk space checks (#287) -- Add ability to attach volumes to a container using 'volumes from' (#244, #289) +- Add ability to change 'tiny image' used for disk space checks ([\#287](https://github.com/testcontainers/testcontainers-java/issues/287)) +- Add ability to attach volumes to a container using 'volumes from' ([\#244](https://github.com/testcontainers/testcontainers-java/issues/244), [\#289](https://github.com/testcontainers/testcontainers-java/issues/289)) ## [1.1.8] - 2017-01-22 -### Fixed -- Compatibility fixes for Docker for Mac v1.13.0 (#272) -- Relax docker environment disk space check to accomodate unusual empty `df` output observed on Docker for Mac with OverlayFS (#273, #278) -- Fix inadvertent private-scoping of startup checks' `StartupStatus`, which made implementation of custom startup checks impossible (#266) -- Fix potential resource lead/deadlock when errors are encountered building images from a Dockerfile (#274) +### Fixed +- Compatibility fixes for Docker for Mac v1.13.0 ([\#272](https://github.com/testcontainers/testcontainers-java/issues/272)) +- Relax docker environment disk space check to accommodate unusual empty `df` output observed on Docker for Mac with OverlayFS ([\#273](https://github.com/testcontainers/testcontainers-java/issues/273), [\#278](https://github.com/testcontainers/testcontainers-java/issues/278)) +- Fix inadvertent private-scoping of startup checks' `StartupStatus`, which made implementation of custom startup checks impossible ([\#266](https://github.com/testcontainers/testcontainers-java/issues/266)) +- Fix potential resource lead/deadlock when errors are encountered building images from a Dockerfile ([\#274](https://github.com/testcontainers/testcontainers-java/issues/274)) ### Changed -- Add support for execution within a Docker container (#267), correcting resolution of container addresses -- Add support for version 2 of private docker registries, configured via `$HOME/.docker/config.json` (#270) -- Use current classloader instead of system classloader for loading JDBC drivers (#261) -- Allow hardcoded container image names for Ambassador and VNC recorder containers to be changed via a configuration file (#277, #259) -- Allow Selenium Webdriver container image name to be specified as a constructor parameter (#249, #171) +- Add support for execution within a Docker container ([\#267](https://github.com/testcontainers/testcontainers-java/issues/267)), correcting resolution of container addresses +- Add support for version 2 of private docker registries, configured via `$HOME/.docker/config.json` ([\#270](https://github.com/testcontainers/testcontainers-java/issues/270)) +- Use current classloader instead of system classloader for loading JDBC drivers ([\#261](https://github.com/testcontainers/testcontainers-java/issues/261)) +- Allow hardcoded container image names for Ambassador and VNC recorder containers to be changed via a configuration file ([\#277](https://github.com/testcontainers/testcontainers-java/issues/277), [\#259](https://github.com/testcontainers/testcontainers-java/issues/259)) +- Allow Selenium Webdriver container image name to be specified as a constructor parameter ([\#249](https://github.com/testcontainers/testcontainers-java/issues/249), [\#171](https://github.com/testcontainers/testcontainers-java/issues/171)) ## [1.1.7] - 2016-11-19 ### Fixed -- Compensate for premature TCP socket opening in Docker for Mac (#160, #236) -- (Internal) Stabilise various parts of Testcontainers' self test suite (#241) -- Fix mounting of classpath resources when those resources are in a JAR file (#213) -- Reduce misleading error messages caused mainly by trying to perform operations on stopped containers (#243) +- Compensate for premature TCP socket opening in Docker for Mac ([\#160](https://github.com/testcontainers/testcontainers-java/issues/160), [\#236](https://github.com/testcontainers/testcontainers-java/issues/236)) +- (Internal) Stabilise various parts of Testcontainers' self test suite ([\#241](https://github.com/testcontainers/testcontainers-java/issues/241)) +- Fix mounting of classpath resources when those resources are in a JAR file ([\#213](https://github.com/testcontainers/testcontainers-java/issues/213)) +- Reduce misleading error messages caused mainly by trying to perform operations on stopped containers ([\#243](https://github.com/testcontainers/testcontainers-java/issues/243)) ### Changed -- Uses a default MySQL and MariaDB configuration to reduce memory footprint (#209, #243) -- Docker Compose can optionally now use a local `docker-compose` executable rather than running inside a container (#200) -- Add support for privileged mode containers (#234, #235) -- Allow container/network cleanup (ResourceReaper) to be triggered programmatically (#231) -- Add optional tailing of logs for containers spawned by Docker Compose (#233) +- Uses a default MySQL and MariaDB configuration to reduce memory footprint ([\#209](https://github.com/testcontainers/testcontainers-java/issues/209), [\#243](https://github.com/testcontainers/testcontainers-java/issues/243)) +- Docker Compose can optionally now use a local `docker-compose` executable rather than running inside a container ([\#200](https://github.com/testcontainers/testcontainers-java/issues/200)) +- Add support for privileged mode containers ([\#234](https://github.com/testcontainers/testcontainers-java/issues/234), [\#235](https://github.com/testcontainers/testcontainers-java/issues/235)) +- Allow container/network cleanup (ResourceReaper) to be triggered programmatically ([\#231](https://github.com/testcontainers/testcontainers-java/issues/231)) +- Add optional tailing of logs for containers spawned by Docker Compose ([\#233](https://github.com/testcontainers/testcontainers-java/issues/233)) - (Internal) Relocate non-proprietary database container tests to a single module ## [1.1.6] - 2016-09-22 ### Fixed -- Fix logging of discovered Docker environment variables (#218) -- Adopt longer timeout periods for testing docker client configurations, and allow these to be further customised through system properties (#217, see *ClientProviderStrategy classes) -- Fix docker compose directory mounting on windows (#224) -- Handle and ignore further categories of failure in retrieval of docker environment disk space (#225) +- Fix logging of discovered Docker environment variables ([\#218](https://github.com/testcontainers/testcontainers-java/issues/218)) +- Adopt longer timeout periods for testing docker client configurations, and allow these to be further customised through system properties ([\#217](https://github.com/testcontainers/testcontainers-java/issues/217), see *ClientProviderStrategy classes) +- Fix docker compose directory mounting on windows ([\#224](https://github.com/testcontainers/testcontainers-java/issues/224)) +- Handle and ignore further categories of failure in retrieval of docker environment disk space ([\#225](https://github.com/testcontainers/testcontainers-java/issues/225)) ### Changed -- Add extra configurability options (database name, username, password) for PostgreSQL DB containers (#220) -- Add MariaDB container type (#215) +- Add extra configurability options (database name, username, password) for PostgreSQL DB containers ([\#220](https://github.com/testcontainers/testcontainers-java/issues/220)) +- Add MariaDB container type ([\#215](https://github.com/testcontainers/testcontainers-java/issues/215)) - Use Docker Compose `down` action for more robust teardown of compose environments -- Ensure that Docker Compose operations run sequentially rather than concurrently if JUnit tests are parallelized (#226) -- Allow multiple Docker Compose files to be specified, to allow for extension/composition of services (#227) +- Ensure that Docker Compose operations run sequentially rather than concurrently if JUnit tests are parallelized ([\#226](https://github.com/testcontainers/testcontainers-java/issues/226)) +- Allow multiple Docker Compose files to be specified, to allow for extension/composition of services ([\#227](https://github.com/testcontainers/testcontainers-java/issues/227)) ## [1.1.5] - 2016-08-22 ### Fixed -- Fix Docker Compose environment variable passthrough (#208) +- Fix Docker Compose environment variable passthrough ([\#208](https://github.com/testcontainers/testcontainers-java/issues/208)) ### Changed -- Remove Docker Compose networks when containers are shut down (#211) as well as at JVM shutdown +- Remove Docker Compose networks when containers are shut down ([\#211](https://github.com/testcontainers/testcontainers-java/issues/211)) as well as at JVM shutdown ## [1.1.4] - 2016-08-16 ### Fixed -- Fix JDBC proxy driver behaviour when used with Tomcat connection pool to avoid spawning excessive numbers of containers (#195) -- Shade Jersey dependencies in JDBC module to avoid classpath conflicts (#202) -- Fix NullPointerException when docker host has untagged images (#201) -- Fix relative paths for volumes mounted in docker-compose containers (#189) +- Fix JDBC proxy driver behaviour when used with Tomcat connection pool to avoid spawning excessive numbers of containers ([\#195](https://github.com/testcontainers/testcontainers-java/issues/195)) +- Shade Jersey dependencies in JDBC module to avoid classpath conflicts ([\#202](https://github.com/testcontainers/testcontainers-java/issues/202)) +- Fix NullPointerException when docker host has untagged images ([\#201](https://github.com/testcontainers/testcontainers-java/issues/201)) +- Fix relative paths for volumes mounted in docker-compose containers ([\#189](https://github.com/testcontainers/testcontainers-java/issues/189)) ### Changed - Update to v3.0.2 of docker-java library -- Switch to a shared, single instance docker client rather than a separate client instance per container rule (#193) -- Ensure that docker-compose pulls images (with no timeout), prior to trying to start (#188) -- Use official `docker/compose` image for running docker-compose (#190) +- Switch to a shared, single instance docker client rather than a separate client instance per container rule ([\#193](https://github.com/testcontainers/testcontainers-java/issues/193)) +- Ensure that docker-compose pulls images (with no timeout), prior to trying to start ([\#188](https://github.com/testcontainers/testcontainers-java/issues/188)) +- Use official `docker/compose` image for running docker-compose ([\#190](https://github.com/testcontainers/testcontainers-java/issues/190)) ## [1.1.3] - 2016-07-27 ### Fixed @@ -105,8 +361,8 @@ All notable changes to this project will be documented in this file. ## [1.1.1] - 2016-07-17 ### Fixed -- Improve shutdown of unnecessary docker clients (#170) -- Shade `io.netty` dependencies into the testcontainers core JAR to reduce conflicts (#170 and #157) +- Improve shutdown of unnecessary docker clients ([\#170](https://github.com/testcontainers/testcontainers-java/issues/170)) +- Shade `io.netty` dependencies into the testcontainers core JAR to reduce conflicts ([\#170](https://github.com/testcontainers/testcontainers-java/issues/170) and [\#157](https://github.com/testcontainers/testcontainers-java/issues/157)) - Remove timeouts for docker compose execution, particularly useful when image pulls are involved - Improve output logging from docker-compose, pausing to log output in case of failure rather than letting logs intermingle. @@ -255,6 +511,12 @@ All notable changes to this project will be documented in this file. ## [0.9] - 2015-04-29 Initial release +[1.5.1]: https://github.com/testcontainers/testcontainers-java/releases/tag/1.5.1 +[1.5.0]: https://github.com/testcontainers/testcontainers-java/releases/tag/1.5.0 +[1.4.2]: https://github.com/testcontainers/testcontainers-java/releases/tag/1.4.3 +[1.4.2]: https://github.com/testcontainers/testcontainers-java/releases/tag/1.4.2 +[1.4.1]: https://github.com/testcontainers/testcontainers-java/releases/tag/1.4.1 +[1.4.0]: https://github.com/testcontainers/testcontainers-java/releases/tag/1.4.0 [1.2.0]: https://github.com/testcontainers/testcontainers-java/releases/tag/testcontainers-1.2.0 [1.1.9]: https://github.com/testcontainers/testcontainers-java/releases/tag/testcontainers-1.1.9 [1.1.8]: https://github.com/testcontainers/testcontainers-java/releases/tag/testcontainers-1.1.8 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000000..27a2ce70f7a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,13 @@ +# Contributing + +Please see the [main contributing guidelines](./docs/contributing.md). + +There are additional docs describing [contributing documentation changes](./docs/contributing_docs.md). + +### GitHub Sponsorship + +Testcontainers is [in the GitHub Sponsors program](https://github.com/sponsors/testcontainers)! + +This repository is supported by our sponsors. If you'd like to support the project, please consider sponsoring us via [GitHub Sponsors](https://github.com/sponsors/testcontainers) or [Open Collective](https://opencollective.com/testcontainers). + +To learn how funds are raised and distributed, see the [Testcontainers project funding policy](https://github.com/testcontainers/.github/blob/main/FUNDING.md). Financial matters are overseen by the Testcontainers Steering Committee, as described in the [project governance document](https://github.com/testcontainers/.github/blob/main/GOVERNANCE.md). diff --git a/LICENSE b/LICENSE index e6d35668f53..9c9e8bc5563 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015 Richard North +Copyright (c) 2015-2019 Richard North Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md index 47e257ed54f..2482154c7be 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,20 @@ -# TestContainers +# Testcontainers -> TestContainers is a Java 8 library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container. +![Maven Central Version](https://img.shields.io/maven-central/v/org.testcontainers/testcontainers) -[![Build Status](https://travis-ci.org/testcontainers/testcontainers-java.svg?branch=master)](https://travis-ci.org/testcontainers/testcontainers-java) +[![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/testcontainers/testcontainers-java?utm_source=oss&utm_medium=github&utm_campaign=testcontainers%2Ftestcontainers-java&labelColor=171717&color=FF570A&label=CodeRabbit+Reviews)](https://coderabbit.ai) + +[![Netlify Status](https://api.netlify.com/api/v1/badges/189f28a2-7faa-42ff-b03c-738142079cc9/deploy-status)](https://app.netlify.com/sites/testcontainers/deploys) + +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=33816473&machine=standardLinux32gb&devcontainer_path=.devcontainer%2Fdevcontainer.json&location=EastUs) + +[![Revved up by Develocity](https://img.shields.io/badge/Revved%20up%20by-Develocity-06A0CE?logo=Gradle&labelColor=02303A)](https://community.develocity.cloud/scans?search.rootProjectNames=testcontainers-java) + +> Testcontainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container. ![Testcontainers logo](docs/logo.png) -# [Read the documentation here](http://www.testcontainers.org) +# [Read the documentation here](https://java.testcontainers.org) ## License @@ -14,6 +22,10 @@ See [LICENSE](LICENSE). ## Copyright -Copyright (c) 2015, 2016 Richard North and other authors. +Copyright (c) 2015 - 2021 Richard North and other authors. + +MS SQL Server module is (c) 2017 - 2021 G DATA Software AG and other authors. + +Hashicorp Vault module is (c) 2017 - 2021 Capital One Services, LLC and other authors. -See [AUTHORS](AUTHORS) for contributors. +See [contributors](https://github.com/testcontainers/testcontainers-java/graphs/contributors) for all contributors. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000000..d5c79b04ed2 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,28 @@ +# Release process + +Testcontainers' release process is semi-automated through GitHub Actions. This describes the basic steps for a project member to perform a release. + +## Steps + +1. Ensure that the `main` branch is building and that tests are passing. +1. Create a new release on GitHub. **The tag name is used as the version**, so please keep the tag name plain (e.g. 1.2.3). +1. The release triggers a GitHub Action workflow. +1. Log in to [Sonatype](https://oss.sonatype.org/) to check the staging repository. + * Getting access to Sonatype requires a Sonatype JIRA account and [raising an issue](https://issues.sonatype.org/browse/OSSRH-74229), requesting access. +3. Get the staging URL from Sonatype after GitHub Action workflow finished. The general URL format should be `https://oss.sonatype.org/service/local/repositories/$staging-repo-id/content/` +4. Manually test the release with the staging URL as maven repository URL (e.g. critical issues and features). +5. Run [TinSalver](https://github.com/bsideup/tinsalver) from GitHub using `npx` to sign artifact (see [TinSalver README](https://github.com/bsideup/tinsalver/blob/main/README.md)). + * For TinSalver to correctly work with keybase on WSL on Windows, you might need to disable pinentry: `keybase config set -b pinentry.disabled true`. +7. Close the release in Sonatype. This will evaluate the release based on given Sonatype rules. +8. After successful closing, the release button needs to be clicked and afterwards it is automatically synced to Maven Central. +9. Handcraft and polish some of the release notes (e.g. substitute combined dependency PRs and highlight certain features). +10. Rename existing milestone corresponding to new release and close it. Then create a new `next` milestone. +11. When available through Maven Central, poke [Richard North](https://github.com/rnorth) to announce the release on Twitter! +12. Merge automated version update PRs in order to update the reference version in `mkdocs.yml` and `gradle.properties`. + +## Internal details + +* The process is done with GitHub Actions, TinSalver and Sonatype. +* Sonatype will automatically promote the staging release to Maven Central. +* Keybase needs to be installed on the developer machine. +* GPG key of signing developer needs to be uploaded to the [Ubuntu keyserver](https://keyserver.ubuntu.com/) (or other server supported by Sonatype). diff --git a/Vagrantfile b/Vagrantfile deleted file mode 100644 index a31b6dd114d..00000000000 --- a/Vagrantfile +++ /dev/null @@ -1,18 +0,0 @@ -# -*- mode: ruby -*- -# vi: set ft=ruby : - -Vagrant.configure(2) do |config| - - config.vm.box = "box-cutter/ubuntu1504-docker" - - config.vm.network "private_network", ip: "192.168.33.10" - - config.vm.provider "virtualbox" do |vb| - vb.memory = "2048" - end - - config.vm.provision "shell", inline: <<-SHELL - sudo apt-get update - sudo apt-get install -y openjdk-8-jdk maven - SHELL -end diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 00000000000..9279b3233f8 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,38 @@ +jobs: + +- job: core_tests + timeoutInMinutes: 60 + steps: + + # Run all core tests when running the Windows CI tests + - task: Gradle@2 + condition: eq(variables['Agent.OS'], 'Windows_NT') + displayName: Build & test (Windows - core) + env: + AWS_ACCESS_KEY_ID: $(aws.accessKeyId) + AWS_SECRET_ACCESS_KEY: $(aws.secretAccessKey) + inputs: + gradleWrapperFile: 'gradlew' + jdkVersionOption: '1.8' + options: '--no-daemon --continue' + tasks: 'clean testcontainers:check' + publishJUnitResults: true + testResultsFiles: '**/TEST-*.xml' + +- job: other_tests + timeoutInMinutes: 120 + steps: + # Run all non-core tests when running the Windows CI tests + - task: Gradle@2 + condition: eq(variables['Agent.OS'], 'Windows_NT') + displayName: Build & test (Windows - all non-core modules) + env: + AWS_ACCESS_KEY_ID: $(aws.accessKeyId) + AWS_SECRET_ACCESS_KEY: $(aws.secretAccessKey) + inputs: + gradleWrapperFile: 'gradlew' + jdkVersionOption: '1.8' + options: '--no-daemon --continue' + tasks: 'clean check -x testcontainers:test' + publishJUnitResults: true + testResultsFiles: '**/TEST-*.xml' diff --git a/bom/build.gradle b/bom/build.gradle new file mode 100644 index 00000000000..6826879ce18 --- /dev/null +++ b/bom/build.gradle @@ -0,0 +1,25 @@ +description = "Testcontainers :: BOM" + +publishing { + publications { + mavenJava(MavenPublication) { publication -> + artifactId = "testcontainers-bom" + artifacts = [] + + pom.withXml { + def dependencyManagementNode = asNode().appendNode('dependencyManagement').appendNode('dependencies') + + def bomProject = project + rootProject.subprojects.each { subProject -> + if (subProject != bomProject && subProject.plugins.findPlugin("maven-publish")) { + dependencyManagementNode.appendNode('dependency').with { + appendNode('groupId', subProject.group) + appendNode('artifactId',subProject.name) + appendNode('version', subProject.version) + } + } + } + } + } + } +} diff --git a/book.js b/book.js deleted file mode 100644 index eaa8f94536d..00000000000 --- a/book.js +++ /dev/null @@ -1,21 +0,0 @@ -module.exports = { - root: "./docs", - - title: "TestContainers", - - structure: { - readme: "index.md" - }, - - plugins: [ - "-highlight", - "prism", - "github", - "expandable-chapters", - ], - "pluginsConfig": { - "github": { - "url": "https://github.com/testcontainers/testcontainers-java/" - } - }, -}; diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000000..f405ea2b92c --- /dev/null +++ b/build.gradle @@ -0,0 +1,148 @@ +buildscript { + repositories { + mavenCentral() + } + + dependencies { + // https://github.com/melix/japicmp-gradle-plugin/issues/36 + classpath 'com.google.guava:guava:33.3.1-jre' + classpath 'com.github.tjni.captainhook:captain-hook:0.1.5' + } +} + +plugins { + id 'io.franzbecker.gradle-lombok' version '5.0.0' + id 'com.gradleup.shadow' version '8.3.9' + id 'me.champeau.gradle.japicmp' version '0.4.3' apply false + id 'com.diffplug.spotless' version '6.22.0' apply false + id 'org.jreleaser' version '1.20.0' apply false +} + +apply from: "$rootDir/gradle/ci-support.gradle" +apply plugin: 'com.github.tjni.captainhook' + +captainHook { + autoApplyGitHooks = Boolean.valueOf(System.getenv("AUTO_APPLY_GIT_HOOKS")) + preCommit = './gradlew spotlessApply' +} + +subprojects { + apply plugin: 'java' + apply plugin: 'java-library' + apply plugin: 'idea' + apply plugin: 'io.franzbecker.gradle-lombok' + apply from: "$rootDir/gradle/shading.gradle" + apply from: "$rootDir/gradle/spotless.gradle" + apply plugin: 'checkstyle' + + group = "org.testcontainers" + + java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } + } + + tasks.withType(JavaCompile) { + options.release.set(8) + options.encoding = 'UTF-8' + } + + compileTestJava.options.encoding = 'UTF-8' + javadoc.options.encoding = 'UTF-8' + + repositories { + mavenCentral() + } + + configurations { + provided + api.extendsFrom(provided) + } + + lombok { + version = '1.18.30' + } + + + task delombok(type: io.franzbecker.gradle.lombok.task.DelombokTask) { + outputs.cacheIf { + true + } + argumentProviders.addAll( + new org.testcontainers.build.DelombokArgumentProvider(srcDirs: project.sourceSets.main.java.srcDirs, outputDir: file("$buildDir/delombok")) + ) + } + delombok.onlyIf { + project.sourceSets.main.java.srcDirs.find { it.exists() } + } + + // specific modules should be excluded from publication + if ( ! ["test-support", "testcontainers-jdbc-test"].contains(it.name) && !it.path.startsWith(":docs:") && it != project(":docs") ) { + apply from: "$rootDir/gradle/publishing.gradle" + + if (it.name != "bom") { + apply plugin: "me.champeau.gradle.japicmp" + tasks.register('japicmp', me.champeau.gradle.japicmp.JapicmpTask) + apply from: "$rootDir/gradle/japicmp.gradle" + } + } + + test { + useJUnitPlatform() + + defaultCharacterEncoding = "UTF-8" + testLogging { + displayGranularity 1 + showStackTraces = true + exceptionFormat = 'full' + events "STARTED", "PASSED", "FAILED", "SKIPPED" + } + ext.isCI = System.getenv("CI") != null + if (isCI) { + develocity.testRetry { + maxRetries = 2 + maxFailures = 5 + failOnPassedAfterRetry = false + } + } + } + + tasks.withType(Test).all { + reports { + junitXml.outputPerTestCase = true + } + } + + // Ensure that Javadoc generation is always tested + check.dependsOn(javadoc) + + def postCheckCommand = properties["postCheckCommand"] + if (postCheckCommand) { + check.finalizedBy(tasks.create("postCheckExec", Exec) { + if (org.gradle.internal.os.OperatingSystem.current().isWindows()) { + commandLine('cmd', '/c', postCheckCommand) + } else { + commandLine('sh', '-c', postCheckCommand) + } + }) + } + + javadoc { + dependsOn delombok + source = delombok.outputs + } + + dependencies { + testImplementation 'ch.qos.logback:logback-classic:1.3.15' + testImplementation 'org.assertj:assertj-core:3.27.4' + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.0' + } + + checkstyle { + toolVersion = "10.23.0" + configFile = rootProject.file('config/checkstyle/checkstyle.xml') + } +} diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle new file mode 100644 index 00000000000..13414d46a22 --- /dev/null +++ b/buildSrc/build.gradle @@ -0,0 +1,7 @@ +plugins { + id 'java-gradle-plugin' +} + +repositories { + mavenCentral() +} diff --git a/buildSrc/src/main/groovy/org/testcontainers/build/ComparePOMWithLatestReleasedTask.groovy b/buildSrc/src/main/groovy/org/testcontainers/build/ComparePOMWithLatestReleasedTask.groovy new file mode 100644 index 00000000000..287f934c1e7 --- /dev/null +++ b/buildSrc/src/main/groovy/org/testcontainers/build/ComparePOMWithLatestReleasedTask.groovy @@ -0,0 +1,36 @@ +package org.testcontainers.build + +import groovy.xml.XmlSlurper +import org.gradle.api.DefaultTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction + +class ComparePOMWithLatestReleasedTask extends DefaultTask { + + @Input + Set ignore = [] + + @TaskAction + def doCompare() { + def rootNode = new XmlSlurper().parse(project.tasks.generatePomFileForMavenJavaPublication.destination) + + def artifactId = rootNode.artifactId.text() + + def latestRelease = new XmlSlurper() + .parse("https://repo1.maven.org/maven2/org/testcontainers/${artifactId}/maven-metadata.xml") + .versioning.release.text() + + def releasedRootNode = new XmlSlurper() + .parse("https://repo1.maven.org/maven2/org/testcontainers/${artifactId}/${latestRelease}/${artifactId}-${latestRelease}.pom") + + Set dependencies = releasedRootNode.dependencies.children() + .collect { "${it.groupId.text()}:${it.artifactId.text()}".toString() } + + for (dependency in rootNode.dependencies.children()) { + def coordinates = "${dependency.groupId.text()}:${dependency.artifactId.text()}".toString() + if (!dependencies.contains(coordinates) && !ignore.contains(coordinates)) { + throw new IllegalStateException("A new dependency '${coordinates}' has been added to 'org.testcontainers:${artifactId}' - if this was intentional please add it to the ignore list in ${project.buildFile}") + } + } + } +} diff --git a/buildSrc/src/main/groovy/org/testcontainers/build/DelombokArgumentProvider.groovy b/buildSrc/src/main/groovy/org/testcontainers/build/DelombokArgumentProvider.groovy new file mode 100644 index 00000000000..2cb4931eb58 --- /dev/null +++ b/buildSrc/src/main/groovy/org/testcontainers/build/DelombokArgumentProvider.groovy @@ -0,0 +1,26 @@ +package org.testcontainers.build + +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.process.CommandLineArgumentProvider + +/** + * Allows build cache relocatability for Delombok task + */ +class DelombokArgumentProvider implements CommandLineArgumentProvider { + + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + Set srcDirs + + @OutputDirectory + File outputDir + + @Override + Iterable asArguments() { + return [srcDirs.collect { it.absolutePath }.join(" "), "-d", outputDir.absolutePath, "-f", "generateDelombokComment:skip"] + } + +} diff --git a/circle.yml b/circle.yml deleted file mode 100644 index 9cb5cdce9fa..00000000000 --- a/circle.yml +++ /dev/null @@ -1,36 +0,0 @@ -machine: - java: - version: oraclejdk8 - environment: - DOCKER_HOST: tcp://127.0.0.1:2376 - DOCKER_TLS_VERIFY: 0 - MAVEN_OPTS: -Xmx512m - _JAVA_OPTIONS: "-Xms512m -Xmx1024m" - services: - - docker - pre: - - sudo curl -L -o /usr/bin/docker 'http://s3-external-1.amazonaws.com/circle-downloads/docker-1.9.1-circleci'; sudo chmod 0755 /usr/bin/docker; true -dependencies: - override: - - "sudo docker -d -e lxc -s btrfs -H 0.0.0.0:2376 -H unix:///var/run/docker.sock --log-level=debug --debug=true": - background: true - - mvn -B install -DskipTests -test: - override: - - mvn -B test - # Run Docker-in-Docker tests - - | - DOCKER_HOST=unix:///var/run/docker.sock DOCKER_TLS_VERIFY= docker run --rm \ - -v "$HOME/.m2":/root/.m2/ \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -v "$(pwd)":"$(pwd)" \ - -w "$(pwd)" \ - openjdk:8-jre \ - ./mvnw -B -pl core test -Dtest=*GenericContainerRuleTest - - mvn -B test -f shade-test/pom.xml - post: - - mkdir -p $CIRCLE_TEST_REPORTS/junit/ - - find . -type f -regex ".*/target/surefire-reports/.*xml" -exec cp {} $CIRCLE_TEST_REPORTS/junit/ \; -notify: - webhooks: - - url: https://webhooks.gitter.im/e/1bf1c6aa3b2aa8408c5f diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 00000000000..e49662a0a0c --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/build.gradle b/core/build.gradle new file mode 100644 index 00000000000..d7a54a26afe --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,145 @@ +apply plugin: 'com.gradleup.shadow' + +description = "Testcontainers Core" + +sourceSets { + jarFileTest +} + +test.maxParallelForks = 4 + +idea.module.testSourceDirs += sourceSets.jarFileTest.allSource.srcDirs + +jar { + manifest { + attributes('Implementation-Version': project.getProperty("version")) + } +} + +shadowJar { + [ + 'META-INF/NOTICE', + 'META-INF/NOTICE.txt', + 'META-INF/LICENSE', + 'META-INF/LICENSE.txt', + 'META-INF/maven/', + 'META-INF/proguard/', + 'META-INF/versions/*/module-info.class', + 'META-INF/services/java.security.Provider', + ].each { exclude(it) } +} + +task jarFileTest(type: Test) { + useJUnitPlatform() + testClassesDirs = sourceSets.jarFileTest.output.classesDirs + classpath = sourceSets.jarFileTest.runtimeClasspath + + inputs.files(shadowJar).withPathSensitivity(PathSensitivity.RELATIVE) + systemProperty("jarFile", project.projectDir.toPath().relativize(shadowJar.outputs.files.singleFile.toPath()).toString()) + + dependsOn(shadowJar) +} +project.tasks.check.dependsOn(jarFileTest) + +tasks.japicmp { + packageExcludes = [ + "com.github.dockerjava.*", + "org.testcontainers.shaded.*", + ] + + classExcludes = [] + + methodExcludes = [] + + fieldExcludes = [] +} + +configurations.all { + resolutionStrategy { + force 'com.fasterxml.jackson.core:jackson-databind:2.18.4' + force 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.4' + } +} + +dependencies { + api 'org.slf4j:slf4j-api:1.7.36' + compileOnly 'org.jetbrains:annotations:26.1.0' + testCompileOnly 'org.jetbrains:annotations:26.1.0' + api 'org.apache.commons:commons-compress:1.28.0' + api ('org.rnorth.duct-tape:duct-tape:1.0.8') { + exclude(group: 'org.jetbrains', module: 'annotations') + } + + provided('com.google.cloud.tools:jib-core:0.28.2') { + exclude group: 'com.google.guava', module: 'guava' + exclude group: 'com.fasterxml.jackson.datatype', module: 'jackson-datatype-jsr310' + exclude group: 'com.fasterxml.jackson.core', module: 'jackson-core' + exclude group: 'com.fasterxml.jackson.core', module: 'jackson-databind' + exclude group: 'org.apache.commons', module: 'commons-compress' + } + + shaded 'org.awaitility:awaitility:4.3.0' + + api platform('com.github.docker-java:docker-java-bom:3.7.1') + shaded platform('com.github.docker-java:docker-java-bom:3.7.1') + + api "com.github.docker-java:docker-java-api" + + shaded('com.github.docker-java:docker-java-core') { + exclude group: 'com.google.guava', module: 'guava' + } + + api 'com.github.docker-java:docker-java-transport-zerodep' + + shaded 'com.google.guava:guava:33.3.1-jre' + shaded "org.yaml:snakeyaml:2.6" + + shaded 'org.glassfish.main.external:trilead-ssh2-repackaged:4.1.2' + + shaded 'org.zeroturnaround:zt-exec:1.13.0' + + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.14.3' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + testImplementation('com.google.cloud.tools:jib-core:0.28.2') { + exclude group: 'com.google.guava', module: 'guava' + } + testImplementation 'org.apache.httpcomponents:httpclient:4.5.14' + testImplementation 'redis.clients:jedis:7.4.1' + testImplementation 'com.rabbitmq:amqp-client:5.35.0' + testImplementation 'org.mongodb:mongo-java-driver:3.12.14' + + testImplementation ('org.mockito:mockito-core:4.11.0') { + exclude(module: 'hamcrest-core') + } + // Synthetic JAR used for MountableFileTest and DirectoryTarResourceTest + testImplementation files('testlib/repo/fakejar/fakejar/0/fakejar-0.jar') + + testImplementation 'org.assertj:assertj-core:3.27.7' + testImplementation 'io.rest-assured:rest-assured:5.5.7' + + jarFileTestCompileOnly "org.projectlombok:lombok:${lombok.version}" + jarFileTestAnnotationProcessor "org.projectlombok:lombok:${lombok.version}" + jarFileTestRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.14.3' + jarFileTestImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + jarFileTestImplementation 'org.assertj:assertj-core:3.27.7' + jarFileTestImplementation 'org.ow2.asm:asm-debug-all:5.2' +} + +tasks.generatePomFileForMavenJavaPublication.finalizedBy( + tasks.register('checkPOMdependencies', org.testcontainers.build.ComparePOMWithLatestReleasedTask) { + ignore = [ + ] + } +) + +compileTestJava { + javaCompiler = javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(17) + } + options.release.set(17) +} + +test { + useJUnitPlatform() +} diff --git a/core/pom.xml b/core/pom.xml deleted file mode 100644 index 0cb5a0c37f6..00000000000 --- a/core/pom.xml +++ /dev/null @@ -1,230 +0,0 @@ - - - 4.0.0 - - - org.testcontainers - testcontainers-parent - 1.2.1-SNAPSHOT - ../pom.xml - - - testcontainers - - TestContainers Core - - - - com.github.docker-java - docker-java - 3.0.7 - - - - de.gesellix - unix-socket-factory - - - - - - org.rnorth - tcp-unix-socket-proxy - 1.0.1 - - - - org.zeroturnaround - zt-exec - 1.8 - - - org.rnorth.duct-tape - duct-tape - 1.0.6 - - - org.slf4j - slf4j-ext - 1.7.2 - - - - - redis.clients - jedis - 2.8.0 - test - - - com.rabbitmq - amqp-client - 3.5.3 - test - - - org.mongodb - mongo-java-driver - 3.0.2 - test - - - org.mockito - mockito-core - 1.10.19 - test - - - org.hamcrest - hamcrest-core - - - - - - - - - - - - - - - - - maven-shade-plugin - 2.4.3 - - - package - - shade - - - - - - - - - - - org.apache.http - org.testcontainers.shaded.org.apache.http - - - org.glassfish - org.testcontainers.shaded.org.glassfish - - - org.jvnet - org.testcontainers.shaded.org.jvnet - - - javax.annotation - org.testcontainers.shaded.javax.annotation - - - javax.inject - org.testcontainers.shaded.javax.inject - - - javax.ws.rs - org.testcontainers.shaded.javax.ws.rs - - - com.fasterxml.jackson - org.testcontainers.shaded.com.fasterxml.jackson - - - com.github.dockerjava - org.testcontainers.shaded.com.github.dockerjava - - - jersey.repackaged - org.testcontainers.shaded.jersey.repackaged - - - com.google - org.testcontainers.shaded.com.google - - - io.netty - org.testcontainers.shaded.io.netty - - - org.bouncycastle - org.testcontainers.shaded.org.bouncycastle - - - org.newsclub - org.testcontainers.shaded.org.newsclub - - - - - org.apache.httpcomponents:* - org.glassfish.*:* - javax.annotation:* - javax.inject:* - javax.ws.rs:* - com.fasterxml.*:* - com.github.docker-java:* - com.google.guava:* - io.netty:* - org.bouncycastle:* - org.newsclub.*:* - - - true - false - - - *:* - - META-INF/*.SF - META-INF/*.DSA - META-INF/*.RSA - META-INF/services/com.fasterxml.jackson.core.* - META-INF/services/com.github.dockerjava.api.command.* - META-INF/services/javax.ws.rs.ext.* - META-INF/services/org.glassfish.hk2.extension.* - META-INF/services/org.jvnet.hk2.external.generator.* - META-INF/services/org.glassfish.jersey.internal.spi.* - - - - - - - org.apache.maven.plugins - maven-antrun-plugin - 1.7 - - - unpack - package - - - - - - - - - - run - - - - - - - diff --git a/core/src/jarFileTest/java/org/testcontainers/AbstractJarFileTest.java b/core/src/jarFileTest/java/org/testcontainers/AbstractJarFileTest.java new file mode 100644 index 00000000000..3c0e056d903 --- /dev/null +++ b/core/src/jarFileTest/java/org/testcontainers/AbstractJarFileTest.java @@ -0,0 +1,27 @@ +package org.testcontainers; + +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; + +public abstract class AbstractJarFileTest { + + public static Path root; + + static { + try { + Path jarFilePath = Paths.get(System.getProperty("jarFile")); + String decodedPath = URLDecoder.decode(jarFilePath.toUri().toString(), StandardCharsets.UTF_8.name()); + URI jarFileUri = new URI("jar", decodedPath, null); + FileSystem fileSystem = FileSystems.newFileSystem(jarFileUri, Collections.emptyMap()); + root = fileSystem.getPath("/"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/core/src/jarFileTest/java/org/testcontainers/JarFileShadingTest.java b/core/src/jarFileTest/java/org/testcontainers/JarFileShadingTest.java new file mode 100644 index 00000000000..8b6f380f6eb --- /dev/null +++ b/core/src/jarFileTest/java/org/testcontainers/JarFileShadingTest.java @@ -0,0 +1,47 @@ +package org.testcontainers; + +import org.assertj.core.api.ListAssert; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class JarFileShadingTest extends AbstractJarFileTest { + + @Test + void testPackages() throws Exception { + assertThatFileList(root).containsOnly("org", "META-INF"); + + assertThatFileList(root.resolve("org")).containsOnly("testcontainers"); + } + + @Test + void testMetaInf() throws Exception { + assertThatFileList(root.resolve("META-INF")) + .containsOnly( + "MANIFEST.MF", + "services", + "versions", + "native-image", + "thirdparty-LICENSE", + "FastDoubleParser-NOTICE", + "FastDoubleParser-LICENSE" + ); + } + + @Test + void testMetaInfServices() throws Exception { + assertThatFileList(root.resolve("META-INF").resolve("services")) + .allMatch(it -> it.startsWith("org.testcontainers.")); + } + + private ListAssert assertThatFileList(Path path) throws IOException { + return (ListAssert) assertThat(Files.list(path)) + .extracting(Path::getFileName) + .extracting(Path::toString) + .extracting(it -> it.endsWith("/") ? it.substring(0, it.length() - 1) : it); + } +} diff --git a/core/src/jarFileTest/java/org/testcontainers/PublicBinaryAPITest.java b/core/src/jarFileTest/java/org/testcontainers/PublicBinaryAPITest.java new file mode 100644 index 00000000000..6f8a1c46941 --- /dev/null +++ b/core/src/jarFileTest/java/org/testcontainers/PublicBinaryAPITest.java @@ -0,0 +1,139 @@ +package org.testcontainers; + +import lombok.RequiredArgsConstructor; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.MethodSource; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldNode; +import org.objectweb.asm.tree.MethodNode; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * This test checks that we don't expose any shaded class in our public API. + */ +@ParameterizedClass +@MethodSource("data") +@RequiredArgsConstructor +public class PublicBinaryAPITest extends AbstractJarFileTest { + + private static final String SHADED_PACKAGE = "org.testcontainers.shaded."; + + private static final String SHADED_PACKAGE_PATH = SHADED_PACKAGE.replaceAll("\\.", "/"); + + static { + Assertions.registerFormatterForType(ClassNode.class, it -> it.name); + Assertions.registerFormatterForType(FieldNode.class, it -> it.name); + Assertions.registerFormatterForType(MethodNode.class, it -> it.name + it.desc); + } + + public static List data() throws Exception { + List result = new ArrayList<>(); + + Files.walkFileTree( + root, + new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException { + String fileName = path.toString(); + + if (!fileName.endsWith(".class")) { + return super.visitFile(path, attrs); + } + + if (!fileName.startsWith("/org/testcontainers/")) { + return super.visitFile(path, attrs); + } + + if (fileName.startsWith("/" + SHADED_PACKAGE_PATH)) { + return super.visitFile(path, attrs); + } + + try (InputStream inputStream = Files.newInputStream(path)) { + ClassReader reader = new ClassReader(inputStream); + ClassNode node = new ClassNode(); + reader.accept(node, ClassReader.SKIP_CODE); + if ((node.access & Opcodes.ACC_PUBLIC) != 0) { + result.add(new Object[] { fileName, node }); + } + } + + return super.visitFile(path, attrs); + } + } + ); + return result; + } + + @Parameter(0) + private String fileName; + + @Parameter(1) + private ClassNode classNode; + + @BeforeEach + public void setUp() { + switch (classNode.name) { + // Necessary evil + case "org/testcontainers/dockerclient/UnixSocketClientProviderStrategy": + case "org/testcontainers/dockerclient/DockerClientProviderStrategy": + case "org/testcontainers/dockerclient/WindowsClientProviderStrategy": + case "org/testcontainers/utility/DynamicPollInterval": + Assumptions.assumeTrue(false); + } + } + + @Test + void testSuperClass() { + assertThat(classNode.superName).doesNotStartWith(SHADED_PACKAGE_PATH); + } + + @Test + void testInterfaces() { + assertThat(classNode.interfaces).allSatisfy(it -> assertThat(it).doesNotStartWith(SHADED_PACKAGE_PATH)); + } + + @Test + void testMethodReturnTypes() { + assertThat(classNode.methods) + .filteredOn(it -> (it.access & (Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED)) != 0) + .allSatisfy(it -> assertThat(Type.getReturnType(it.desc).getClassName()).doesNotStartWith(SHADED_PACKAGE)); + } + + @Test + void testMethodArguments() { + assertThat(classNode.methods) + .filteredOn(it -> (it.access & (Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED)) != 0) + .allSatisfy(method -> { + assertThat(Arrays.asList(Type.getArgumentTypes(method.desc))) + .extracting(Type::getClassName) + .allSatisfy(it -> assertThat(it).doesNotStartWith(SHADED_PACKAGE)); + }); + } + + @Test + void testFields() { + assertThat(classNode.fields) + .filteredOn(it -> (it.access & (Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED)) != 0) + .allSatisfy(it -> assertThat(Type.getType(it.desc).getClassName()).doesNotStartWith(SHADED_PACKAGE)); + } +} diff --git a/core/src/main/java/org/testcontainers/DelegatingDockerClient.java b/core/src/main/java/org/testcontainers/DelegatingDockerClient.java new file mode 100644 index 00000000000..ca25650bb02 --- /dev/null +++ b/core/src/main/java/org/testcontainers/DelegatingDockerClient.java @@ -0,0 +1,12 @@ +package org.testcontainers; + +import com.github.dockerjava.api.DockerClient; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +@RequiredArgsConstructor +class DelegatingDockerClient implements DockerClient { + + @Delegate + private final DockerClient dockerClient; +} diff --git a/core/src/main/java/org/testcontainers/DockerClientFactory.java b/core/src/main/java/org/testcontainers/DockerClientFactory.java index 0e68cc87965..989aaf36be3 100644 --- a/core/src/main/java/org/testcontainers/DockerClientFactory.java +++ b/core/src/main/java/org/testcontainers/DockerClientFactory.java @@ -1,25 +1,48 @@ package org.testcontainers; import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.DockerClientDelegate; import com.github.dockerjava.api.command.CreateContainerCmd; +import com.github.dockerjava.api.command.PullImageCmd; +import com.github.dockerjava.api.exception.DockerClientException; import com.github.dockerjava.api.exception.InternalServerErrorException; import com.github.dockerjava.api.exception.NotFoundException; -import com.github.dockerjava.api.model.Image; +import com.github.dockerjava.api.model.AccessMode; +import com.github.dockerjava.api.model.Bind; import com.github.dockerjava.api.model.Info; import com.github.dockerjava.api.model.Version; -import com.github.dockerjava.core.command.PullImageResultCallback; - +import com.github.dockerjava.api.model.Volume; +import com.google.common.annotations.VisibleForTesting; +import lombok.Getter; +import lombok.SneakyThrows; import lombok.Synchronized; import lombok.extern.slf4j.Slf4j; -import org.testcontainers.dockerclient.*; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.SystemUtils; +import org.testcontainers.dockerclient.DockerClientProviderStrategy; +import org.testcontainers.dockerclient.DockerMachineClientProviderStrategy; +import org.testcontainers.dockerclient.TransportConfig; +import org.testcontainers.images.RemoteDockerImage; +import org.testcontainers.images.TimeLimitedLoggedPullImageResultCallback; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; +import org.testcontainers.utility.ResourceReaper; import org.testcontainers.utility.TestcontainersConfiguration; +import java.io.InputStream; +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; import java.util.List; -import java.util.Optional; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.UUID; import java.util.function.BiFunction; import java.util.function.Consumer; - -import static java.util.Arrays.asList; +import java.util.stream.Collectors; /** * Singleton class that provides initialized Docker clients. @@ -29,31 +52,67 @@ @Slf4j public class DockerClientFactory { - private static final String TINY_IMAGE = TestcontainersConfiguration.getInstance().getTinyImage(); + public static final ThreadGroup TESTCONTAINERS_THREAD_GROUP = new ThreadGroup("testcontainers"); + + public static final String TESTCONTAINERS_LABEL = DockerClientFactory.class.getPackage().getName(); + + public static final String TESTCONTAINERS_SESSION_ID_LABEL = TESTCONTAINERS_LABEL + ".sessionId"; + + public static final String TESTCONTAINERS_LANG_LABEL = TESTCONTAINERS_LABEL + ".lang"; + + public static final String TESTCONTAINERS_VERSION_LABEL = TESTCONTAINERS_LABEL + ".version"; + + public static final String SESSION_ID = UUID.randomUUID().toString(); + + public static final String TESTCONTAINERS_VERSION = + DockerClientFactory.class.getPackage().getImplementationVersion(); + + public static final Map DEFAULT_LABELS = markerLabels(); + + static Map markerLabels() { + String testcontainersVersion = TESTCONTAINERS_VERSION == null ? "unspecified" : TESTCONTAINERS_VERSION; + + Map labels = new HashMap<>(); + labels.put(TESTCONTAINERS_LABEL, "true"); + labels.put(TESTCONTAINERS_LANG_LABEL, "java"); + labels.put(TESTCONTAINERS_VERSION_LABEL, testcontainersVersion); + return Collections.unmodifiableMap(labels); + } + + private static final DockerImageName TINY_IMAGE = DockerImageName.parse("alpine:3.17"); + private static DockerClientFactory instance; // Cached client configuration - private DockerClientProviderStrategy strategy; - private boolean preconditionsChecked = false; - - private static final List CONFIGURATION_STRATEGIES = - asList(new EnvironmentAndSystemPropertyClientProviderStrategy(), - new UnixSocketClientProviderStrategy(), - new ProxiedUnixSocketClientProviderStrategy(), - new DockerMachineClientProviderStrategy(), - new WindowsClientProviderStrategy()); + @VisibleForTesting + DockerClientProviderStrategy strategy; + + @VisibleForTesting + DockerClient client; + + @VisibleForTesting + RuntimeException cachedClientFailure; + private String activeApiVersion; - private String activeExecutionDriver; - static { - System.setProperty("org.testcontainers.shaded.io.netty.packagePrefix", "org.testcontainers.shaded."); - } + @Getter(lazy = true) + private final boolean fileMountingSupported = checkMountableFile(); - /** - * Private constructor - */ - private DockerClientFactory() { + @VisibleForTesting + DockerClientFactory() {} + + public static DockerClient lazyClient() { + return new DockerClientDelegate() { + @Override + protected DockerClient getDockerClient() { + return instance().client(); + } + @Override + public String toString() { + return "LazyDockerClient"; + } + }; } /** @@ -61,7 +120,7 @@ private DockerClientFactory() { * * @return the singleton instance of DockerClientFactory */ - public synchronized static DockerClientFactory instance() { + public static synchronized DockerClientFactory instance() { if (instance == null) { instance = new DockerClientFactory(); } @@ -70,147 +129,257 @@ public synchronized static DockerClientFactory instance() { } /** + * Checks whether Docker is accessible and {@link #client()} is able to produce a client. * + * @return true if Docker is available, false if not. + */ + public synchronized boolean isDockerAvailable() { + try { + client(); + return true; + } catch (IllegalStateException ex) { + return false; + } + } + + @Synchronized + private DockerClientProviderStrategy getOrInitializeStrategy() { + if (strategy != null) { + return strategy; + } + log.info("Testcontainers version: {}", DEFAULT_LABELS.get(TESTCONTAINERS_VERSION_LABEL)); + List configurationStrategies = new ArrayList<>(); + ServiceLoader.load(DockerClientProviderStrategy.class).forEach(configurationStrategies::add); + + strategy = DockerClientProviderStrategy.getFirstValidStrategy(configurationStrategies); + return strategy; + } + + @UnstableAPI + public TransportConfig getTransportConfig() { + return getOrInitializeStrategy().getTransportConfig(); + } + + @UnstableAPI + public String getRemoteDockerUnixSocketPath() { + DockerClientProviderStrategy strategy = getOrInitializeStrategy(); + if (strategy.allowUserOverrides()) { + String dockerSocketOverride = System.getenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE"); + if (!StringUtils.isBlank(dockerSocketOverride)) { + return dockerSocketOverride; + } + } + if (strategy.getRemoteDockerUnixSocketPath() != null) { + return strategy.getRemoteDockerUnixSocketPath(); + } + + URI dockerHost = getTransportConfig().getDockerHost(); + String path = "unix".equals(dockerHost.getScheme()) ? dockerHost.getRawPath() : "/var/run/docker.sock"; + return SystemUtils.IS_OS_WINDOWS ? "/" + path : path; + } + + /** * @return a new initialized Docker client */ @Synchronized public DockerClient client() { + // fail-fast if checks have failed previously + if (cachedClientFailure != null) { + log.debug("There is a cached checks failure - throwing", cachedClientFailure); + throw cachedClientFailure; + } - if (strategy != null) { - return strategy.getClient(); + if (client != null) { + return client; } - strategy = DockerClientProviderStrategy.getFirstValidStrategy(CONFIGURATION_STRATEGIES); + final DockerClientProviderStrategy strategy = getOrInitializeStrategy(); + client = + new DockerClientDelegate() { + @Getter + final DockerClient dockerClient = strategy.getDockerClient(); + + @Override + public void close() { + throw new IllegalStateException("You should never close the global DockerClient!"); + } + }; log.info("Docker host IP address is {}", strategy.getDockerHostIpAddress()); - DockerClient client = strategy.getClient(); - - if (!preconditionsChecked) { - Info dockerInfo = client.infoCmd().exec(); - Version version = client.versionCmd().exec(); - activeApiVersion = version.getApiVersion(); - activeExecutionDriver = dockerInfo.getExecutionDriver(); - log.info("Connected to docker: \n" + - " Server Version: " + dockerInfo.getServerVersion() + "\n" + - " API Version: " + activeApiVersion + "\n" + - " Operating System: " + dockerInfo.getOperatingSystem() + "\n" + - " Total Memory: " + dockerInfo.getMemTotal() / (1024 * 1024) + " MB"); - - checkVersion(version.getVersion()); - - List images = client.listImagesCmd().exec(); - // Pull the image we use to perform some checks - if (images.stream().noneMatch(it -> it.getRepoTags() != null && asList(it.getRepoTags()).contains(TINY_IMAGE))) { - client.pullImageCmd(TINY_IMAGE).exec(new PullImageResultCallback()).awaitSuccess(); - } - checkDiskSpaceAndHandleExceptions(client); - preconditionsChecked = true; + Info dockerInfo = strategy.getInfo(); + log.debug("Docker info: {}", dockerInfo.getRawValues()); + Version version = client.versionCmd().exec(); + log.debug("Docker version: {}", version.getRawValues()); + activeApiVersion = version.getApiVersion(); + + String serverInfo = + "Connected to docker: \n" + + " Server Version: " + + dockerInfo.getServerVersion() + + "\n" + + " API Version: " + + activeApiVersion + + "\n" + + " Operating System: " + + dockerInfo.getOperatingSystem() + + "\n" + + " Total Memory: " + + dockerInfo.getMemTotal() / + (1024 * 1024) + + " MB"; + + String[] labels = dockerInfo.getLabels(); + boolean hasLabels = labels != null && labels.length > 0; + if (hasLabels) { + String formattedLabels = Arrays + .stream(labels) + .map(label -> " " + label) + .collect(Collectors.joining("\n")); + serverInfo += "\n Labels: \n" + formattedLabels; + } + log.info(serverInfo); + + try { + //noinspection deprecation + ResourceReaper.instance().init(); + } catch (RuntimeException e) { + cachedClientFailure = e; + throw e; + } + + boolean checksEnabled = !TestcontainersConfiguration.getInstance().isDisableChecks(); + if (checksEnabled) { + log.debug("Checks are enabled"); + + try { + log.info("Checking the system..."); + checkDockerVersion(version.getVersion()); + } catch (RuntimeException e) { + cachedClientFailure = e; + throw e; + } + } else { + log.debug("Checks are disabled"); } return client; } - /** - * @return the IP address of the host running Docker - */ - public String dockerHostIpAddress() { - return strategy.getDockerHostIpAddress(); + private void checkDockerVersion(String dockerVersion) { + boolean versionIsSufficient = new ComparableVersion(dockerVersion).compareTo(new ComparableVersion("1.6.0")) >= + 0; + check("Docker server version should be at least 1.6.0", versionIsSufficient); } - private void checkVersion(String version) { - String[] splitVersion = version.split("\\."); - if (Integer.valueOf(splitVersion[0]) <= 1 && Integer.valueOf(splitVersion[1]) < 6) { - throw new IllegalStateException("Docker version 1.6.0+ is required, but version " + version + " was found"); + private void check(String message, boolean isSuccessful) { + if (isSuccessful) { + log.info("\u2714\ufe0e {}", message); + } else { + log.error("\u274c {}", message); + throw new IllegalStateException("Check failed: " + message); } } - private void checkDiskSpaceAndHandleExceptions(DockerClient client) { + private boolean checkMountableFile() { + DockerClient dockerClient = client(); + + MountableFile mountableFile = MountableFile.forClasspathResource( + ResourceReaper.class.getName().replace(".", "/") + ".class" + ); + + Volume volume = new Volume("/dummy"); try { - checkDiskSpace(client); - } catch (NotEnoughDiskSpaceException e) { - throw e; + return runInsideDocker( + createContainerCmd -> { + createContainerCmd.withBinds(new Bind(mountableFile.getResolvedPath(), volume, AccessMode.ro)); + }, + (__, containerId) -> { + try ( + InputStream stream = dockerClient + .copyArchiveFromContainerCmd(containerId, volume.getPath()) + .exec() + ) { + stream.read(); + return true; + } catch (Exception e) { + return false; + } + } + ); } catch (Exception e) { - log.warn("Encountered and ignored error while checking disk space", e); + log.debug("Failure while checking for mountable file support", e); + return false; } } /** - * Check whether this docker installation is likely to have disk space problems - * @param client an active Docker client + * Check whether the image is available locally and pull it otherwise + * + * @deprecated use {@link RemoteDockerImage} */ - private void checkDiskSpace(DockerClient client) { - DiskSpaceUsage df = runInsideDocker(client, cmd -> cmd.withCmd("df", "-P"), (dockerClient, id) -> { - String logResults = dockerClient.logContainerCmd(id) - .withStdOut(true) - .exec(new LogToStringContainerCallback()) - .toString(); - - return parseAvailableDiskSpace(logResults); - }); - - log.info("Disk utilization in Docker environment is {} ({} )", - df.usedPercent.map(x -> x + "%").orElse("unknown"), - df.availableMB.map(x -> x + " MB available").orElse("unknown available")); - - if (df.availableMB.map(it -> it < 2048).orElse(false)) { - log.error("Docker environment has less than 2GB free - execution is unlikely to succeed so will be aborted."); - throw new NotEnoughDiskSpaceException("Not enough disk space in Docker environment"); + @SneakyThrows + @Deprecated + public void checkAndPullImage(DockerClient client, String image) { + try { + client.inspectImageCmd(image).exec(); + } catch (NotFoundException notFoundException) { + PullImageCmd pullImageCmd = client.pullImageCmd(image); + try { + pullImageCmd.exec(new TimeLimitedLoggedPullImageResultCallback(log)).awaitCompletion(); + } catch (DockerClientException e) { + // Try to fallback to x86 + pullImageCmd + .withPlatform("linux/amd64") + .exec(new TimeLimitedLoggedPullImageResultCallback(log)) + .awaitCompletion(); + } } } - public T runInsideDocker(Consumer createContainerCmdConsumer, BiFunction block) { - if (strategy == null) { - client(); - } - // We can't use client() here because it might create an infinite loop - return runInsideDocker(strategy.getClient(), createContainerCmdConsumer, block); + /** + * @return the IP address of the host running Docker + */ + public String dockerHostIpAddress() { + return getOrInitializeStrategy().getDockerHostIpAddress(); } - private T runInsideDocker(DockerClient client, Consumer createContainerCmdConsumer, BiFunction block) { - CreateContainerCmd createContainerCmd = client.createContainerCmd(TINY_IMAGE); + public T runInsideDocker( + Consumer createContainerCmdConsumer, + BiFunction block + ) { + return runInsideDocker(TINY_IMAGE, createContainerCmdConsumer, block); + } + + T runInsideDocker( + DockerImageName imageName, + Consumer createContainerCmdConsumer, + BiFunction block + ) { + RemoteDockerImage dockerImage = new RemoteDockerImage(imageName); + HashMap labels = new HashMap<>(DEFAULT_LABELS); + labels.putAll(ResourceReaper.instance().getLabels()); + CreateContainerCmd createContainerCmd = client.createContainerCmd(dockerImage.get()).withLabels(labels); createContainerCmdConsumer.accept(createContainerCmd); String id = createContainerCmd.exec().getId(); - client.startContainerCmd(id).exec(); - try { + client.startContainerCmd(id).exec(); return block.apply(client, id); } finally { try { client.removeContainerCmd(id).withRemoveVolumes(true).withForce(true).exec(); - } catch (NotFoundException | InternalServerErrorException ignored) { - log.debug("", ignored); - } - } - } - - private static class DiskSpaceUsage { - Optional availableMB = Optional.empty(); - Optional usedPercent = Optional.empty(); - } - - private DiskSpaceUsage parseAvailableDiskSpace(String dfOutput) { - DiskSpaceUsage df = new DiskSpaceUsage(); - String[] lines = dfOutput.split("\n"); - for (String line : lines) { - String[] fields = line.split("\\s+"); - if (fields[5].equals("/")) { - int availableKB = Integer.valueOf(fields[3]); - df.availableMB = Optional.of(availableKB / 1024); - df.usedPercent = Optional.of(Integer.valueOf(fields[4].replace("%", ""))); + } catch (NotFoundException | InternalServerErrorException e) { + log.debug("Swallowed exception while removing container", e); } } - return df; } /** * @return the docker API version of the daemon that we have connected to */ public String getActiveApiVersion() { - if (!preconditionsChecked) { - client(); - } + client(); return activeApiVersion; } @@ -218,10 +387,7 @@ public String getActiveApiVersion() { * @return the docker execution driver of the daemon that we have connected to */ public String getActiveExecutionDriver() { - if (!preconditionsChecked) { - client(); - } - return activeExecutionDriver; + return getInfo().getExecutionDriver(); } /** @@ -229,12 +395,11 @@ public String getActiveExecutionDriver() { * @return whether or not the currently active strategy is of the provided type */ public boolean isUsing(Class providerStrategyClass) { - return providerStrategyClass.isAssignableFrom(this.strategy.getClass()); + return strategy != null && providerStrategyClass.isAssignableFrom(this.strategy.getClass()); } - private static class NotEnoughDiskSpaceException extends RuntimeException { - NotEnoughDiskSpaceException(String message) { - super(message); - } + @UnstableAPI + public Info getInfo() { + return getOrInitializeStrategy().getInfo(); } } diff --git a/core/src/main/java/org/testcontainers/Testcontainers.java b/core/src/main/java/org/testcontainers/Testcontainers.java new file mode 100644 index 00000000000..ab567e1fda7 --- /dev/null +++ b/core/src/main/java/org/testcontainers/Testcontainers.java @@ -0,0 +1,23 @@ +package org.testcontainers; + +import lombok.experimental.UtilityClass; +import org.testcontainers.containers.PortForwardingContainer; + +import java.util.Map; +import java.util.Map.Entry; + +@UtilityClass +public class Testcontainers { + + public void exposeHostPorts(int... ports) { + for (int port : ports) { + PortForwardingContainer.INSTANCE.exposeHostPort(port); + } + } + + public void exposeHostPorts(Map ports) { + for (Entry entry : ports.entrySet()) { + PortForwardingContainer.INSTANCE.exposeHostPort(entry.getKey(), entry.getValue()); + } + } +} diff --git a/core/src/main/java/org/testcontainers/UnstableAPI.java b/core/src/main/java/org/testcontainers/UnstableAPI.java new file mode 100644 index 00000000000..01221738dd4 --- /dev/null +++ b/core/src/main/java/org/testcontainers/UnstableAPI.java @@ -0,0 +1,17 @@ +package org.testcontainers; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks that the annotated API is a subject to change and SHOULD NOT be considered + * a stable API. + */ +@Retention(RetentionPolicy.SOURCE) +@Target({ ElementType.TYPE, ElementType.METHOD, ElementType.FIELD }) +@Documented +public @interface UnstableAPI { +} diff --git a/core/src/main/java/org/testcontainers/containers/AmbassadorContainer.java b/core/src/main/java/org/testcontainers/containers/AmbassadorContainer.java deleted file mode 100644 index 756b88d05ab..00000000000 --- a/core/src/main/java/org/testcontainers/containers/AmbassadorContainer.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.testcontainers.containers; - -import com.github.dockerjava.api.exception.DockerException; -import com.github.dockerjava.api.model.Container; -import lombok.Data; -import lombok.EqualsAndHashCode; -import org.testcontainers.containers.traits.LinkableContainer; -import org.testcontainers.utility.TestcontainersConfiguration; - -import java.util.List; - -import static java.util.Arrays.asList; - -/** - * An ambassador container is used as a TCP proxy, enabling any TCP port of another linked container to be exposed - * publicly, even if that container does not make the port public itself. The richnorth/ambassador:latest - * container is used (based on HAProxy). - */ -@EqualsAndHashCode(callSuper = false) -@Data -public class AmbassadorContainer> extends GenericContainer { - - private final String otherContainerName; - private final String serviceName; - private final int servicePort; - - public AmbassadorContainer(LinkableContainer otherContainer, String serviceName, int servicePort) { - super(TestcontainersConfiguration.getInstance().getAmbassadorContainerImage()); - - /* - Use the unique 'identifierPrefix' (random compose project name) so that the ambassador can see - the container it's supposed to be proxying. - */ - this.otherContainerName = otherContainer.getContainerName(); - this.serviceName = serviceName; - this.servicePort = servicePort; - - // Link - addLink(otherContainer, serviceName); - - // Expose ambassador's port - addExposedPort(servicePort); - - // Tell the proxy what to connect to within the docker network - addEnv("SERVICE_NAME", serviceName); - addEnv("SERVICE_PORT", String.format("%d", servicePort)); - } - - public boolean isServiceReady() { - try { - List allContainers = dockerClient.listContainersCmd().withShowAll(true).exec(); - - for (Container container : allContainers) { - if (asList(container.getNames()).contains("/" + otherContainerName) && !container.getStatus().contains("Exited")) { - return true; - } - } - - } catch (DockerException e) { - return false; - } - return false; - } -} diff --git a/core/src/main/java/org/testcontainers/containers/BindMode.java b/core/src/main/java/org/testcontainers/containers/BindMode.java index 579dc4d5eca..5fc9c610165 100644 --- a/core/src/main/java/org/testcontainers/containers/BindMode.java +++ b/core/src/main/java/org/testcontainers/containers/BindMode.java @@ -6,7 +6,8 @@ * Possible modes for binding storage volumes. */ public enum BindMode { - READ_ONLY(AccessMode.ro), READ_WRITE(AccessMode.rw); + READ_ONLY(AccessMode.ro), + READ_WRITE(AccessMode.rw); public final AccessMode accessMode; diff --git a/core/src/main/java/org/testcontainers/containers/ComposeCommand.java b/core/src/main/java/org/testcontainers/containers/ComposeCommand.java new file mode 100644 index 00000000000..f603761e3ce --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/ComposeCommand.java @@ -0,0 +1,35 @@ +package org.testcontainers.containers; + +import java.util.Set; + +class ComposeCommand { + + static String getDownCommand(ComposeDelegate.ComposeVersion composeVersion, Set options) { + String composeOptions = optionsAsString(options); + if (composeOptions.isEmpty()) { + return composeVersion == ComposeDelegate.ComposeVersion.V1 ? "down" : "compose down"; + } + String cmd = composeVersion == ComposeDelegate.ComposeVersion.V1 ? "%s down" : "compose %s down"; + return String.format(cmd, composeOptions); + } + + static String getUpCommand(ComposeDelegate.ComposeVersion composeVersion, Set options) { + String composeOptions = optionsAsString(options); + if (composeOptions.isEmpty()) { + return composeVersion == ComposeDelegate.ComposeVersion.V1 ? "up -d" : "compose up -d"; + } + String cmd = composeVersion == ComposeDelegate.ComposeVersion.V1 ? "%s up -d" : "compose %s up -d"; + return String.format(cmd, composeOptions); + } + + private static String optionsAsString(final Set options) { + String optionsString = String.join(" ", options); + if (!optionsString.isEmpty()) { + // ensures that there is a space between the options and 'up' if options are passed. + return optionsString; + } else { + // otherwise two spaces would appear between 'docker-compose' and 'up' + return ""; + } + } +} diff --git a/core/src/main/java/org/testcontainers/containers/ComposeContainer.java b/core/src/main/java/org/testcontainers/containers/ComposeContainer.java new file mode 100644 index 00000000000..539ca957764 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/ComposeContainer.java @@ -0,0 +1,421 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.model.Container; +import com.google.common.annotations.VisibleForTesting; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.SystemUtils; +import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.utility.Base58; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +/** + * Testcontainers implementation for Docker Compose V2.
+ * It uses either Compose V2 contained within the Docker binary, or a containerised version of Compose V2. + */ +@Slf4j +public class ComposeContainer implements Startable { + + private final Map scalingPreferences = new HashMap<>(); + + private boolean localCompose; + + private boolean pull = true; + + private boolean build = false; + + private Set options = new HashSet<>(); + + private boolean tailChildContainers; + + private static final Object MUTEX = new Object(); + + private List services = new ArrayList<>(); + + /** + * Properties that should be passed through to all Compose and ambassador containers (not + * necessarily to containers that are spawned by Compose itself) + */ + private Map env = new HashMap<>(); + + private RemoveImages removeImages; + + private boolean removeVolumes = true; + + public static final String COMPOSE_EXECUTABLE = SystemUtils.IS_OS_WINDOWS ? "docker.exe" : "docker"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("docker"); + + private final ComposeDelegate composeDelegate; + + private String project; + + private List filesInDirectory = new ArrayList<>(); + + /** + * Creates a new ComposeContainer using the specified Docker image and compose files. + * + * @param image The Docker image to use for the container + * @param composeFiles One or more Docker Compose configuration files + */ + public ComposeContainer(DockerImageName image, File... composeFiles) { + this(image, Arrays.asList(composeFiles)); + } + + /** + * Creates a new ComposeContainer using the specified Docker image and compose files. + * + * @param image The Docker image to use for the container + * @param composeFiles A list of Docker Compose configuration files + */ + public ComposeContainer(DockerImageName image, List composeFiles) { + this(image, Base58.randomString(6).toLowerCase(), composeFiles); + } + + /** + * Creates a new ComposeContainer with the specified Docker image, identifier, and compose files. + * + * @param image The Docker image to use for the container + * @param identifier A unique identifier for this compose environment + * @param composeFiles One or more Docker Compose configuration files + */ + public ComposeContainer(DockerImageName image, String identifier, File... composeFiles) { + this(image, identifier, Arrays.asList(composeFiles)); + } + + /** + * Creates a new ComposeContainer with the specified Docker image, identifier, and a single compose file. + * + * @param image The Docker image to use for the container + * @param identifier A unique identifier for this compose environment + * @param composeFile A Docker Compose configuration file + */ + public ComposeContainer(DockerImageName image, String identifier, File composeFile) { + this(image, identifier, Collections.singletonList(composeFile)); + } + + /** + * Creates a new ComposeContainer with the specified Docker image, identifier, and compose files. + * + * @param image The Docker image to use for the container + * @param identifier A unique identifier for this compose environment + * @param composeFiles A list of Docker Compose configuration files + */ + public ComposeContainer(DockerImageName image, String identifier, List composeFiles) { + image.assertCompatibleWith(DEFAULT_IMAGE_NAME); + this.composeDelegate = + new ComposeDelegate(ComposeDelegate.ComposeVersion.V2, composeFiles, identifier, COMPOSE_EXECUTABLE, image); + this.project = this.composeDelegate.getProject(); + } + + /** + * Use the new constructor {@link #ComposeContainer(DockerImageName image, File... composeFiles)} + */ + public ComposeContainer(File... composeFiles) { + this(DEFAULT_IMAGE_NAME, Arrays.asList(composeFiles)); + this.localCompose = true; + } + + /** + * Use the new constructor {@link #ComposeContainer(DockerImageName image, List composeFiles)} + */ + public ComposeContainer(List composeFiles) { + this(DEFAULT_IMAGE_NAME, composeFiles); + this.localCompose = true; + } + + /** + * Use the new constructor {@link #ComposeContainer(DockerImageName image, String identifier, File... composeFile)} + */ + public ComposeContainer(String identifier, File... composeFiles) { + this(DEFAULT_IMAGE_NAME, identifier, Arrays.asList(composeFiles)); + this.localCompose = true; + } + + /** + * Use the new constructor {@link #ComposeContainer(DockerImageName image, String identifier, List composeFiles)} + */ + public ComposeContainer(String identifier, List composeFiles) { + this(DEFAULT_IMAGE_NAME, identifier, composeFiles); + this.localCompose = true; + } + + @Override + public void start() { + synchronized (MUTEX) { + this.composeDelegate.registerContainersForShutdown(); + if (pull) { + try { + this.composeDelegate.pullImages(); + } catch (ContainerLaunchException e) { + log.warn("Exception while pulling images, using local images if available", e); + } + } + this.composeDelegate.createServices( + this.localCompose, + this.build, + this.options, + this.services, + this.scalingPreferences, + this.env, + this.filesInDirectory + ); + this.composeDelegate.startAmbassadorContainer(); + this.composeDelegate.waitUntilServiceStarted(this.tailChildContainers); + } + } + + @VisibleForTesting + List listChildContainers() { + return this.composeDelegate.listChildContainers(); + } + + public ComposeContainer withServices(@NonNull String... services) { + this.services = Arrays.asList(services); + return this; + } + + @Override + public void stop() { + synchronized (MUTEX) { + try { + this.composeDelegate.getAmbassadorContainer().stop(); + + // Kill the services using docker + String cmd = ComposeCommand.getDownCommand(ComposeDelegate.ComposeVersion.V2, this.options); + + if (removeVolumes) { + cmd += " -v"; + } + if (removeImages != null) { + cmd += " --rmi " + removeImages.dockerRemoveImagesType(); + } + this.composeDelegate.runWithCompose(this.localCompose, cmd, this.env, this.filesInDirectory); + } finally { + this.composeDelegate.clear(); + this.project = this.composeDelegate.randomProjectId(); + } + } + } + + public ComposeContainer withExposedService(String serviceName, int servicePort) { + this.composeDelegate.withExposedService(serviceName, servicePort, Wait.defaultWaitStrategy()); + return this; + } + + public ComposeContainer withExposedService(String serviceName, int instance, int servicePort) { + return withExposedService(serviceName + "-" + instance, servicePort); + } + + public ComposeContainer withExposedService( + String serviceName, + int instance, + int servicePort, + WaitStrategy waitStrategy + ) { + this.composeDelegate.withExposedService(serviceName + "-" + instance, servicePort, waitStrategy); + return this; + } + + public ComposeContainer withExposedService( + String serviceName, + int servicePort, + @NonNull WaitStrategy waitStrategy + ) { + this.composeDelegate.withExposedService(serviceName, servicePort, waitStrategy); + return this; + } + + /** + * Specify the {@link WaitStrategy} to use to determine if the container is ready. + * + * @param serviceName the name of the service to wait for + * @param waitStrategy the WaitStrategy to use + * @return this + * @see org.testcontainers.containers.wait.strategy.Wait#defaultWaitStrategy() + */ + public ComposeContainer waitingFor(String serviceName, @NonNull WaitStrategy waitStrategy) { + String serviceInstanceName = this.composeDelegate.getServiceInstanceName(serviceName); + this.composeDelegate.addWaitStrategy(serviceInstanceName, waitStrategy); + return this; + } + + /** + * Get the host (e.g. IP address or hostname) that an exposed service can be found at, from the host machine + * (i.e. should be the machine that's running this Java process). + *

+ * The service must have been declared using ComposeContainer#withExposedService. + * + * @param serviceName the name of the service as set in the docker-compose.yml file. + * @param servicePort the port exposed by the service container. + * @return a host IP address or hostname that can be used for accessing the service container. + */ + public String getServiceHost(String serviceName, Integer servicePort) { + return this.composeDelegate.getServiceHost(); + } + + /** + * Get the port that an exposed service can be found at, from the host machine + * (i.e. should be the machine that's running this Java process). + *

+ * The service must have been declared using ComposeContainer#withExposedService. + * + * @param serviceName the name of the service as set in the docker-compose.yml file. + * @param servicePort the port exposed by the service container. + * @return a port that can be used for accessing the service container. + */ + public Integer getServicePort(String serviceName, Integer servicePort) { + return this.composeDelegate.getServicePort(serviceName, servicePort); + } + + public ComposeContainer withScaledService(String serviceBaseName, int numInstances) { + scalingPreferences.put(serviceBaseName, numInstances); + return this; + } + + public ComposeContainer withEnv(String key, String value) { + env.put(key, value); + return this; + } + + public ComposeContainer withEnv(Map env) { + env.forEach(this.env::put); + return this; + } + + /** + * Whether to pull images first. + * + * @return this instance, for chaining + */ + public ComposeContainer withPull(boolean pull) { + this.pull = pull; + return this; + } + + /** + * Whether to tail child container logs. + * + * @return this instance, for chaining + */ + public ComposeContainer withTailChildContainers(boolean tailChildContainers) { + this.tailChildContainers = tailChildContainers; + return this; + } + + /** + * Attach an output consumer at container startup, enabling stdout and stderr to be followed, waited on, etc. + *

+ * More than one consumer may be registered. + * + * @param serviceName the name of the service as set in the docker-compose.yml file + * @param consumer consumer that output frames should be sent to + * @return this instance, for chaining + */ + public ComposeContainer withLogConsumer(String serviceName, Consumer consumer) { + this.composeDelegate.withLogConsumer(serviceName, consumer); + return this; + } + + /** + * Whether to always build images before starting containers. + * + * @return this instance, for chaining + */ + public ComposeContainer withBuild(boolean build) { + this.build = build; + return this; + } + + /** + * Adds options to the docker command, e.g. docker --compatibility. + * + * @return this instance, for chaining + */ + public ComposeContainer withOptions(String... options) { + this.options = new HashSet<>(Arrays.asList(options)); + return this; + } + + /** + * Remove images after containers shutdown. + * + * @return this instance, for chaining + */ + public ComposeContainer withRemoveImages(ComposeContainer.RemoveImages removeImages) { + this.removeImages = removeImages; + return this; + } + + /** + * Remove volumes after containers shut down. + * + * @param removeVolumes whether volumes are to be removed. + * @return this instance, for chaining. + */ + public ComposeContainer withRemoveVolumes(boolean removeVolumes) { + this.removeVolumes = removeVolumes; + return this; + } + + /** + * Set the maximum startup timeout all the waits set are bounded to. + * + * @return this instance. for chaining + */ + public ComposeContainer withStartupTimeout(Duration startupTimeout) { + this.composeDelegate.setStartupTimeout(startupTimeout); + return this; + } + + public ComposeContainer withCopyFilesInContainer(String... fileCopyInclusions) { + this.filesInDirectory = Arrays.asList(fileCopyInclusions); + return this; + } + + public Optional getContainerByServiceName(String serviceName) { + return this.composeDelegate.getContainerByServiceName(serviceName); + } + + private void followLogs(String containerId, Consumer consumer) { + this.followLogs(containerId, consumer); + } + + public enum RemoveImages { + /** + * Remove all images used by any service. + */ + ALL("all"), + + /** + * Remove only images that don't have a custom tag set by the `image` field. + */ + LOCAL("local"); + + private final String dockerRemoveImagesType; + + RemoveImages(final String dockerRemoveImagesType) { + this.dockerRemoveImagesType = dockerRemoveImagesType; + } + + public String dockerRemoveImagesType() { + return dockerRemoveImagesType; + } + } +} diff --git a/core/src/main/java/org/testcontainers/containers/ComposeDelegate.java b/core/src/main/java/org/testcontainers/containers/ComposeDelegate.java new file mode 100644 index 00000000000..3365a0b0681 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/ComposeDelegate.java @@ -0,0 +1,411 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.model.Container; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.collect.Sets; +import lombok.Getter; +import lombok.NonNull; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.images.RemoteDockerImage; +import org.testcontainers.utility.Base58; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.ImageNameSubstitutor; +import org.testcontainers.utility.LogUtils; +import org.testcontainers.utility.ResourceReaper; + +import java.io.File; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Slf4j +class ComposeDelegate { + + private final ComposeVersion composeVersion; + + private final String composeSeparator; + + private final DockerClient dockerClient; + + private final List composeFiles; + + private final DockerComposeFiles dockerComposeFiles; + + private final String identifier; + + @Getter + private final String project; + + private final String executable; + + private final DockerImageName defaultImageName; + + private final AtomicInteger nextAmbassadorPort = new AtomicInteger(2000); + + private final Map> ambassadorPortMappings = new ConcurrentHashMap<>(); + + private final Map>> logConsumers = new ConcurrentHashMap<>(); + + @Getter + private final SocatContainer ambassadorContainer = new SocatContainer(); + + private final Map serviceInstanceMap = new ConcurrentHashMap<>(); + + private final Map waitStrategyMap = new ConcurrentHashMap<>(); + + @Setter + private Duration startupTimeout = Duration.ofMinutes(30); + + ComposeDelegate( + ComposeVersion composeVersion, + List composeFiles, + String identifier, + String executable, + DockerImageName defaultImageName + ) { + this.composeVersion = composeVersion; + this.composeSeparator = composeVersion.getSeparator(); + this.dockerClient = DockerClientFactory.lazyClient(); + this.composeFiles = composeFiles; + this.dockerComposeFiles = new DockerComposeFiles(this.composeFiles); + this.identifier = identifier.toLowerCase(); + this.project = randomProjectId(); + this.executable = executable; + this.defaultImageName = defaultImageName; + } + + void pullImages() { + // Pull images using our docker client rather than compose itself, + // (a) as a workaround for https://github.com/docker/compose/issues/5854, which prevents authenticated image pulls being possible when credential helpers are in use + // (b) so that credential helper-based auth still works when compose is running from within a container + this.dockerComposeFiles.getDependencyImages() + .forEach(imageName -> { + try { + log.info( + "Preemptively checking local images for '{}', referenced via a compose file or transitive Dockerfile. If not available, it will be pulled.", + imageName + ); + new RemoteDockerImage(DockerImageName.parse(imageName)) + .withImageNameSubstitutor(ImageNameSubstitutor.noop()) + .get(); + } catch (Exception e) { + log.warn( + "Unable to pre-fetch an image ({}) depended upon by Docker Compose build - startup will continue but may fail. Exception message was: {}", + imageName, + e.getMessage() + ); + } + }); + } + + void createServices( + boolean localCompose, + boolean build, + final Set options, + final List services, + final Map scalingPreferences, + Map env, + List fileCopyInclusions + ) { + // services that have been explicitly requested to be started. If empty, all services should be started. + final String serviceNameArgs = Stream + .concat( + services.stream(), // services that have been specified with `withServices` + scalingPreferences.keySet().stream() // services that are implicitly needed via `withScaledService` + ) + .distinct() + .collect(Collectors.joining(" ")); + + // Apply scaling for the services specified using `withScaledService` + final String scalingOptions = scalingPreferences + .entrySet() + .stream() + .map(entry -> "--scale " + entry.getKey() + "=" + entry.getValue()) + .distinct() + .collect(Collectors.joining(" ")); + + String command = ComposeCommand.getUpCommand(this.composeVersion, options); + + if (build) { + command += " --build"; + } + + if (!Strings.isNullOrEmpty(scalingOptions)) { + command += " " + scalingOptions; + } + + if (!Strings.isNullOrEmpty(serviceNameArgs)) { + command += " " + serviceNameArgs; + } + + // Run the docker compose container, which starts up the services + runWithCompose(localCompose, command, env, fileCopyInclusions); + } + + void waitUntilServiceStarted(boolean tailChildContainers) { + listChildContainers().forEach(container -> createServiceInstance(container, tailChildContainers)); + + Set servicesToWaitFor = waitStrategyMap.keySet(); + Set instantiatedServices = serviceInstanceMap.keySet(); + Sets.SetView missingServiceInstances = Sets.difference(servicesToWaitFor, instantiatedServices); + + if (!missingServiceInstances.isEmpty()) { + throw new IllegalStateException( + "Services named " + + missingServiceInstances + + " " + + "do not exist, but wait conditions have been defined " + + "for them. This might mean that you misspelled " + + "the service name when defining the wait condition." + ); + } + + serviceInstanceMap.forEach(this::waitUntilServiceStarted); + } + + private void createServiceInstance(Container container, boolean tailChildContainers) { + String serviceName = getServiceNameFromContainer(container); + final ComposeServiceWaitStrategyTarget containerInstance = new ComposeServiceWaitStrategyTarget( + dockerClient, + container, + ambassadorContainer, + ambassadorPortMappings.getOrDefault(serviceName, new HashMap<>()) + ); + + String containerId = containerInstance.getContainerId(); + if (tailChildContainers) { + followLogs(containerId, new Slf4jLogConsumer(log).withPrefix(container.getNames()[0])); + } + //follow logs using registered consumers for this service + logConsumers + .getOrDefault(serviceName, Collections.emptyList()) + .forEach(consumer -> followLogs(containerId, consumer)); + serviceInstanceMap.putIfAbsent(serviceName, containerInstance); + } + + private void waitUntilServiceStarted(String serviceName, ComposeServiceWaitStrategyTarget serviceInstance) { + final WaitAllStrategy waitAllStrategy = waitStrategyMap.get(serviceName); + if (waitAllStrategy != null) { + waitAllStrategy.waitUntilReady(serviceInstance); + } + } + + private String getServiceNameFromContainer(com.github.dockerjava.api.model.Container container) { + final String containerName = container.getLabels().get("com.docker.compose.service"); + final String containerNumber = container.getLabels().get("com.docker.compose.container-number"); + return String.format("%s%s%s", containerName, this.composeSeparator, containerNumber); + } + + public void runWithCompose(boolean localCompose, String cmd) { + runWithCompose(localCompose, cmd, Collections.emptyMap(), Collections.emptyList()); + } + + public void runWithCompose( + boolean localCompose, + String cmd, + Map env, + List fileCopyInclusions + ) { + Preconditions.checkNotNull(composeFiles); + Preconditions.checkArgument(!composeFiles.isEmpty(), "No docker compose file have been provided"); + + final DockerCompose dockerCompose; + if (localCompose) { + dockerCompose = new LocalDockerCompose(this.executable, composeFiles, project); + } else { + dockerCompose = + new ContainerisedDockerCompose(this.defaultImageName, composeFiles, project, fileCopyInclusions); + } + + dockerCompose.withCommand(cmd).withEnv(env).invoke(); + } + + void registerContainersForShutdown() { + ResourceReaper + .instance() + .registerLabelsFilterForCleanup(Collections.singletonMap("com.docker.compose.project", project)); + } + + @VisibleForTesting + List listChildContainers() { + return dockerClient + .listContainersCmd() + .withShowAll(true) + .exec() + .stream() + .filter(container -> Arrays.stream(container.getNames()).anyMatch(name -> name.startsWith("/" + project))) + .collect(Collectors.toList()); + } + + void startAmbassadorContainer() { + if (!this.ambassadorPortMappings.isEmpty()) { + this.ambassadorContainer.start(); + } + } + + public void withExposedService(String serviceName, int servicePort) { + withExposedService(serviceName, servicePort, Wait.defaultWaitStrategy()); + } + + public void withExposedService(String serviceName, int instance, int servicePort) { + withExposedService(serviceName + this.composeSeparator + instance, servicePort); + } + + public void withExposedService(String serviceName, int instance, int servicePort, WaitStrategy waitStrategy) { + withExposedService(serviceName + this.composeSeparator + instance, servicePort, waitStrategy); + } + + public void withExposedService(String serviceName, int servicePort, @NonNull WaitStrategy waitStrategy) { + String serviceInstanceName = getServiceInstanceName(serviceName); + + /* + * For every service/port pair that needs to be exposed, we register a target on an 'ambassador container'. + * + * The ambassador container's role is to link (within the Docker network) to one of the + * compose services, and proxy TCP network I/O out to a port that the ambassador container + * exposes. + * + * This avoids the need for the docker compose file to explicitly expose ports on all the + * services. + * + * {@link GenericContainer} should ensure that the ambassador container is on the same network + * as the rest of the compose environment. + */ + + // Ambassador container will be started together after docker compose has started + int ambassadorPort = nextAmbassadorPort.getAndIncrement(); + ambassadorPortMappings + .computeIfAbsent(serviceInstanceName, __ -> new ConcurrentHashMap<>()) + .put(servicePort, ambassadorPort); + ambassadorContainer.withTarget(ambassadorPort, serviceInstanceName, servicePort); + ambassadorContainer.addLink( + new FutureContainer(this.project + this.composeSeparator + serviceInstanceName), + serviceInstanceName + ); + addWaitStrategy(serviceInstanceName, waitStrategy); + } + + String getServiceInstanceName(String serviceName) { + String serviceInstanceName = serviceName; + String regex = String.format(".*%s[0-9]+", this.composeSeparator); + if (!serviceInstanceName.matches(regex)) { + serviceInstanceName += String.format("%s1", this.composeSeparator); // implicit first instance of this service + } + return serviceInstanceName; + } + + /* + * can have multiple wait strategies for a single container, e.g. if waiting on several ports + * if no wait strategy is defined, the WaitAllStrategy will return immediately. + * The WaitAllStrategy uses the startup timeout for everything as a global maximum, but we expect timeouts to be handled by the inner strategies. + */ + void addWaitStrategy(String serviceInstanceName, @NonNull WaitStrategy waitStrategy) { + final WaitAllStrategy waitAllStrategy = waitStrategyMap.computeIfAbsent( + serviceInstanceName, + __ -> { + return new WaitAllStrategy(WaitAllStrategy.Mode.WITH_MAXIMUM_OUTER_TIMEOUT) + .withStartupTimeout(startupTimeout); + } + ); + waitAllStrategy.withStrategy(waitStrategy); + } + + /** + * Get the port that an exposed service can be found at, from the host machine + * (i.e. should be the machine that's running this Java process). + *

+ * The service must have been declared using DockerComposeContainer#withExposedService. + * + * @param serviceName the name of the service as set in the docker-compose.yml file. + * @param servicePort the port exposed by the service container. + * @return a port that can be used for accessing the service container. + */ + public Integer getServicePort(String serviceName, Integer servicePort) { + Map portMap = this.ambassadorPortMappings.get(getServiceInstanceName(serviceName)); + + if (portMap == null) { + throw new IllegalArgumentException( + "Could not get a port for '" + + serviceName + + "'. " + + "Testcontainers does not have an exposed port configured for '" + + serviceName + + "'. " + + "To fix, please ensure that the service '" + + serviceName + + "' has ports exposed using .withExposedService(...)" + ); + } else { + return ambassadorContainer.getMappedPort(portMap.get(servicePort)); + } + } + + Optional getContainerByServiceName(String serviceName) { + String serviceInstantName = getServiceInstanceName(serviceName); + return Optional.ofNullable(serviceInstanceMap.get(serviceInstantName)); + } + + private void followLogs(String containerId, Consumer consumer) { + LogUtils.followOutput(dockerClient, containerId, consumer); + } + + String randomProjectId() { + return this.identifier + Base58.randomString(6).toLowerCase(); + } + + void withLogConsumer(String serviceName, Consumer consumer) { + String serviceInstanceName = getServiceInstanceName(serviceName); + final List> consumers = + this.logConsumers.getOrDefault(serviceInstanceName, new ArrayList<>()); + consumers.add(consumer); + this.logConsumers.putIfAbsent(serviceInstanceName, consumers); + } + + String getServiceHost() { + return this.ambassadorContainer.getHost(); + } + + void clear() { + this.logConsumers.clear(); + this.ambassadorPortMappings.clear(); + this.serviceInstanceMap.clear(); + this.waitStrategyMap.clear(); + } + + enum ComposeVersion { + V1("_"), + + V2("-"); + + private final String separator; + + ComposeVersion(String separator) { + this.separator = separator; + } + + public String getSeparator() { + return this.separator; + } + } +} diff --git a/core/src/main/java/org/testcontainers/containers/ComposeServiceWaitStrategyTarget.java b/core/src/main/java/org/testcontainers/containers/ComposeServiceWaitStrategyTarget.java new file mode 100644 index 00000000000..81525710700 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/ComposeServiceWaitStrategyTarget.java @@ -0,0 +1,77 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.Container; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NonNull; +import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Class to provide a wait strategy target for services started through docker-compose + */ +@EqualsAndHashCode +class ComposeServiceWaitStrategyTarget implements WaitStrategyTarget { + + private final Container container; + + private final GenericContainer proxyContainer; + + private final DockerClient dockerClient; + + @NonNull + private Map mappedPorts; + + @Getter(lazy = true) + private final InspectContainerResponse containerInfo = dockerClient.inspectContainerCmd(getContainerId()).exec(); + + ComposeServiceWaitStrategyTarget( + DockerClient dockerClient, + Container container, + GenericContainer proxyContainer, + @NonNull Map mappedPorts + ) { + this.dockerClient = dockerClient; + this.container = container; + this.proxyContainer = proxyContainer; + this.mappedPorts = new HashMap<>(mappedPorts); + } + + /** + * {@inheritDoc} + */ + @Override + public List getExposedPorts() { + return new ArrayList<>(this.mappedPorts.keySet()); + } + + /** + * {@inheritDoc} + */ + @Override + public Integer getMappedPort(int originalPort) { + return this.proxyContainer.getMappedPort(this.mappedPorts.get(originalPort)); + } + + /** + * {@inheritDoc} + */ + @Override + public String getHost() { + return proxyContainer.getHost(); + } + + /** + * {@inheritDoc} + */ + @Override + public String getContainerId() { + return this.container.getId(); + } +} diff --git a/core/src/main/java/org/testcontainers/containers/Container.java b/core/src/main/java/org/testcontainers/containers/Container.java index b3c0a5298da..abea7fef576 100644 --- a/core/src/main/java/org/testcontainers/containers/Container.java +++ b/core/src/main/java/org/testcontainers/containers/Container.java @@ -1,26 +1,28 @@ package org.testcontainers.containers; -import com.github.dockerjava.api.DockerClient; -import com.github.dockerjava.api.command.InspectContainerResponse; import com.github.dockerjava.api.model.Bind; -import com.github.dockerjava.api.model.Info; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.NonNull; +import lombok.Value; import org.testcontainers.containers.output.OutputFrame; import org.testcontainers.containers.startupcheck.StartupCheckStrategy; import org.testcontainers.containers.traits.LinkableContainer; -import org.testcontainers.containers.wait.Wait; -import org.testcontainers.containers.wait.WaitStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.images.ImagePullPolicy; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.LogUtils; +import org.testcontainers.utility.MountableFile; -import java.io.IOException; -import java.nio.charset.Charset; import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.Future; import java.util.function.Consumer; +import java.util.function.Function; -public interface Container> extends LinkableContainer { - +public interface Container> extends LinkableContainer, ContainerState { /** * @return a reference to this container instance, cast to the expected generic type. */ @@ -30,25 +32,17 @@ default SELF self() { } /** - * Class to hold results from a "docker exec" command. Note that, due to the limitations of the - * docker API, there's no easy way to get the result code from the process we ran. + * Class to hold results from a "docker exec" command */ + @Value + @AllArgsConstructor(access = AccessLevel.MODULE) class ExecResult { - private final String stdout; - private final String stderr; - public ExecResult(String stdout, String stderr) { - this.stdout = stdout; - this.stderr = stderr; - } + int exitCode; - public String getStdout() { - return stdout; - } + String stdout; - public String getStderr() { - return stderr; - } + String stderr; } /** @@ -80,18 +74,37 @@ public String getStderr() { * Adds a file system binding. Consider using {@link #withFileSystemBind(String, String, BindMode)} * for building a container in a fluent style. * - * @param hostPath the file system path on the host + * @param hostPath the file system path on the host * @param containerPath the file system path inside the container - * @param mode the bind mode + * @param mode the bind mode + * @deprecated use {@link GenericContainer#withCopyToContainer(Transferable, String)} */ - void addFileSystemBind(String hostPath, String containerPath, BindMode mode); + @Deprecated + default void addFileSystemBind(final String hostPath, final String containerPath, final BindMode mode) { + addFileSystemBind(hostPath, containerPath, mode, SelinuxContext.SHARED); + } + + /** + * Adds a file system binding. Consider using {@link #withFileSystemBind(String, String, BindMode)} + * for building a container in a fluent style. + * + * @param hostPath the file system path on the host + * @param containerPath the file system path inside the container + * @param mode the bind mode + * @param selinuxContext selinux context argument to use for this file + * @deprecated use {@link GenericContainer#withCopyToContainer(Transferable, String)} + */ + @Deprecated + void addFileSystemBind(String hostPath, String containerPath, BindMode mode, SelinuxContext selinuxContext); /** * Add a link to another container. * * @param otherContainer the other container object to link to * @param alias the alias (for the other container) that this container should be able to use + * @deprecated Links are deprecated (see #465). Please use {@link Network} features instead. */ + @Deprecated void addLink(LinkableContainer otherContainer, String alias); /** @@ -113,12 +126,25 @@ public String getStderr() { /** * Specify the {@link WaitStrategy} to use to determine if the container is ready. * - * @see Wait#defaultWaitStrategy() + * @see org.testcontainers.containers.wait.strategy.Wait#defaultWaitStrategy() * @param waitStrategy the WaitStrategy to use * @return this */ SELF waitingFor(@NonNull WaitStrategy waitStrategy); + /** + * Adds a file system binding. + * + * @param hostPath the file system path on the host + * @param containerPath the file system path inside the container + * @return this + * @deprecated use {@link GenericContainer#withCopyToContainer(Transferable, String)} + */ + @Deprecated + default SELF withFileSystemBind(String hostPath, String containerPath) { + return withFileSystemBind(hostPath, containerPath, BindMode.READ_WRITE); + } + /** * Adds a file system binding. * @@ -126,7 +152,9 @@ public String getStderr() { * @param containerPath the file system path inside the container * @param mode the bind mode * @return this + * @deprecated use {@link GenericContainer#withCopyToContainer(Transferable, String)} */ + @Deprecated SELF withFileSystemBind(String hostPath, String containerPath, BindMode mode); /** @@ -146,6 +174,27 @@ public String getStderr() { */ SELF withExposedPorts(Integer... ports); + /** + * Set the file to be copied before starting a created container + * + * @param mountableFile a Mountable file with path of source file / folder on host machine + * @param containerPath a destination path on container to which the files / folders to be copied + * @return this + * + * @deprecated Use {@link #withCopyToContainer(Transferable, String)} instead + */ + @Deprecated + SELF withCopyFileToContainer(MountableFile mountableFile, String containerPath); + + /** + * Set the content to be copied before starting a created container + * + * @param transferable a Transferable + * @param containerPath a destination path on container to which the files / folders to be copied + * @return this + */ + SELF withCopyToContainer(Transferable transferable, String containerPath); + /** * Add an environment variable to be passed to the container. * @@ -155,6 +204,18 @@ public String getStderr() { */ SELF withEnv(String key, String value); + /** + * Add an environment variable to be passed to the container. + * + * @param key environment variable key + * @param mapper environment variable value mapper, accepts old value as an argument + * @return this + */ + default SELF withEnv(String key, Function, String> mapper) { + Optional oldValue = Optional.ofNullable(getEnvMap().get(key)); + return withEnv(key, mapper.apply(oldValue)); + } + /** * Add environment variables to be passed to the container. * @@ -163,6 +224,22 @@ public String getStderr() { */ SELF withEnv(Map env); + /** + * Add a label to the container. + * + * @param key label key + * @param value label value + * @return this + */ + SELF withLabel(String key, String value); + + /** + * Add labels to the container. + * @param labels map of labels + * @return this + */ + SELF withLabels(Map labels); + /** * Set the command that should be run in the container * @@ -196,6 +273,30 @@ public String getStderr() { */ SELF withNetworkMode(String networkMode); + /** + * Set the network for this container, similar to the --network <name> + * option on the docker CLI. + * + * @param network the instance of {@link Network} + * @return this + */ + SELF withNetwork(Network network); + + /** + * Set the network aliases for this container, similar to the --network-alias <my-service> + * option on the docker CLI. + * + * @param aliases the list of aliases + * @return this + */ + SELF withNetworkAliases(String... aliases); + + /** + * Set the image pull policy of the container + * @return + */ + SELF withImagePullPolicy(ImagePullPolicy policy); + /** * Map a resource (file or directory) on the classpath to a path inside the container. * This will only work if you are running your tests outside a Docker container. @@ -204,12 +305,40 @@ public String getStderr() { * @param containerPath path this should be mapped to inside the container * @param mode access mode for the file * @return this + * @deprecated use {@link GenericContainer#withCopyToContainer(Transferable, String)} */ - SELF withClasspathResourceMapping(String resourcePath, String containerPath, BindMode mode); + @Deprecated + default SELF withClasspathResourceMapping( + final String resourcePath, + final String containerPath, + final BindMode mode + ) { + withClasspathResourceMapping(resourcePath, containerPath, mode, SelinuxContext.SHARED); + return self(); + } + + /** + * Map a resource (file or directory) on the classpath to a path inside the container. + * This will only work if you are running your tests outside a Docker container. + * + * @param resourcePath path to the resource on the classpath (relative to the classpath root; should not start with a leading slash) + * @param containerPath path this should be mapped to inside the container + * @param mode access mode for the file + * @param selinuxContext selinux context argument to use for this file + * @return this + * @deprecated use {@link GenericContainer#withCopyToContainer(Transferable, String)} + */ + @Deprecated + SELF withClasspathResourceMapping( + String resourcePath, + String containerPath, + BindMode mode, + SelinuxContext selinuxContext + ); /** * Set the duration of waiting time until container treated as started. - * @see WaitStrategy#waitUntilReady(GenericContainer) + * @see WaitStrategy#waitUntilReady(org.testcontainers.containers.wait.strategy.WaitStrategyTarget) * * @param startupTimeout timeout * @return this @@ -223,13 +352,6 @@ public String getStderr() { */ SELF withPrivilegedMode(boolean mode); - /** - * Get the IP address that this container may be reached on (may not be the local machine). - * - * @return an IP address - */ - String getContainerIpAddress(); - /** * Only consider a container to have successfully started if it has been running for this duration. The default * value is null; if that's the value, ignore this check. @@ -253,19 +375,6 @@ public String getStderr() { */ SELF withWorkingDirectory(String workDir); - /** - * @return is the container currently running? - */ - Boolean isRunning(); - - /** - * Get the actual mapped port for a given port exposed by the container. - * - * @param originalPort the original TCP port that is exposed - * @return the port that the exposed port is mapped to, or null if it is not exposed - */ - Integer getMappedPort(int originalPort); - /** * Resolve Docker image and set it. * @@ -290,7 +399,9 @@ public String getStderr() { * from inside the container is not going to work, since the container has its own IP address. * * @return the IP address of the host machine + * @deprecated use {@link org.testcontainers.Testcontainers#exposeHostPorts(int...)} */ + @Deprecated String getTestHostIpAddress(); /** @@ -298,7 +409,9 @@ public String getStderr() { * * @param consumer consumer that the frames should be sent to */ - void followOutput(Consumer consumer); + default void followOutput(Consumer consumer) { + LogUtils.followOutput(getDockerClient(), getContainerId(), consumer); + } /** * Follow container output, sending each frame (usually, line) to a consumer. This method allows Stdout and/or stderr @@ -307,8 +420,9 @@ public String getStderr() { * @param consumer consumer that the frames should be sent to * @param types types that should be followed (one or both of STDOUT, STDERR) */ - void followOutput(Consumer consumer, OutputFrame.OutputType... types); - + default void followOutput(Consumer consumer, OutputFrame.OutputType... types) { + LogUtils.followOutput(getDockerClient(), getContainerId(), consumer, types); + } /** * Attach an output consumer at container startup, enabling stdout and stderr to be followed, waited on, etc. @@ -320,58 +434,31 @@ public String getStderr() { */ SELF withLogConsumer(Consumer consumer); - Info fetchDockerDaemonInfo() throws IOException; - - /** - * Run a command inside a running container, as though using "docker exec", and interpreting - * the output as UTF8. - *

- * @see #execInContainer(Charset, String...) - */ - ExecResult execInContainer(String... command) - throws UnsupportedOperationException, IOException, InterruptedException; - - /** - * Run a command inside a running container, as though using "docker exec". - *

- * This functionality is not available on a docker daemon running the older "lxc" execution driver. At - * the time of writing, CircleCI was using this driver. - * @param outputCharset the character set used to interpret the output. - * @param command the parts of the command to run - * @return the result of execution - * @throws IOException if there's an issue communicating with Docker - * @throws InterruptedException if the thread waiting for the response is interrupted - * @throws UnsupportedOperationException if the docker daemon you're connecting to doesn't support "exec". - */ - ExecResult execInContainer(Charset outputCharset, String... command) - throws UnsupportedOperationException, IOException, InterruptedException; - - List getExposedPorts(); - List getPortBindings(); List getExtraHosts(); Future getImage(); + /** + * + * @deprecated use getEnvMap + */ + @Deprecated List getEnv(); + Map getEnvMap(); + String[] getCommandParts(); List getBinds(); + /** + * @deprecated Links are deprecated (see #465). Please use {@link Network} features instead. + */ + @Deprecated Map getLinkedContainers(); - DockerClient getDockerClient(); - - Info getDockerDaemonInfo(); - - String getContainerId(); - - String getContainerName(); - - InspectContainerResponse getContainerInfo(); - void setExposedPorts(List exposedPorts); void setPortBindings(List portBindings); @@ -386,37 +473,11 @@ ExecResult execInContainer(Charset outputCharset, String... command) void setBinds(List binds); - void setLinkedContainers(Map linkedContainers); - /** - * @deprecated set by GenericContainer and should never be set outside + * @deprecated Links are deprecated (see #465). Please use {@link Network} features instead. */ @Deprecated - void setDockerClient(DockerClient dockerClient); - - /** - * @deprecated set by GenericContainer and should never be set outside - */ - @Deprecated - void setDockerDaemonInfo(Info dockerDaemonInfo); - - /** - * @deprecated set by GenericContainer and should never be set outside - */ - @Deprecated - void setContainerId(String containerId); - - /** - * @deprecated set by GenericContainer and should never be set outside - */ - @Deprecated - void setContainerName(String containerName); + void setLinkedContainers(Map linkedContainers); void setWaitStrategy(WaitStrategy waitStrategy); - - /** - * @deprecated set by GenericContainer and should never be set outside - */ - @Deprecated - void setContainerInfo(InspectContainerResponse containerInfo); } diff --git a/core/src/main/java/org/testcontainers/containers/ContainerDef.java b/core/src/main/java/org/testcontainers/containers/ContainerDef.java new file mode 100644 index 00000000000..5dd3abb37bd --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/ContainerDef.java @@ -0,0 +1,304 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import com.github.dockerjava.api.model.Bind; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.HostConfig; +import com.github.dockerjava.api.model.InternetProtocol; +import com.github.dockerjava.api.model.PortBinding; +import com.github.dockerjava.api.model.Ports; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.UnstableAPI; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.images.RemoteDockerImage; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.TestcontainersConfiguration; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@UnstableAPI +@Slf4j +class ContainerDef { + + @Getter + private RemoteDockerImage image; + + Set exposedPorts = new LinkedHashSet<>(); + + Set portBindings = new HashSet<>(); + + Map labels = new HashMap<>(); + + Map envVars = new HashMap<>(); + + private String[] entrypoint; + + private String[] command = new String[0]; + + @Getter + private Network network; + + Set networkAliases = new LinkedHashSet<>(); + + @Getter + private String networkMode; + + List binds = new ArrayList<>(); + + @Getter + private boolean privilegedMode; + + @Getter + private WaitStrategy waitStrategy = GenericContainer.DEFAULT_WAIT_STRATEGY; + + public ContainerDef() {} + + protected void applyTo(CreateContainerCmd createCommand) { + HostConfig hostConfig = createCommand.getHostConfig(); + if (hostConfig == null) { + hostConfig = new HostConfig(); + createCommand.withHostConfig(hostConfig); + } + // PortBindings must contain: + // * all exposed ports with a randomized host port (equivalent to -p CONTAINER_PORT) + // * all exposed ports with a fixed host port (equivalent to -p HOST_PORT:CONTAINER_PORT) + Map allPortBindings = new HashMap<>(); + // First, collect all the randomized host ports from our 'exposedPorts' field + for (ExposedPort exposedPort : this.exposedPorts) { + allPortBindings.put(exposedPort, new PortBinding(Ports.Binding.empty(), exposedPort)); + } + // Next, collect all the fixed host ports from our 'portBindings' field, overwriting any randomized ports so that + // we don't create two bindings for the same container port. + for (PortBinding portBinding : this.portBindings) { + allPortBindings.put(portBinding.getExposedPort(), portBinding); + } + hostConfig.withPortBindings(new ArrayList<>(allPortBindings.values())); + + // Next, ExposedPorts must be set up to publish all of the above ports, both randomized and fixed. + createCommand.withExposedPorts(new ArrayList<>(allPortBindings.keySet())); + + createCommand.withEnv( + this.envVars.entrySet() + .stream() + .filter(it -> it.getValue() != null) + .map(it -> it.getKey() + "=" + it.getValue()) + .toArray(String[]::new) + ); + + if (this.entrypoint != null) { + createCommand.withEntrypoint(this.entrypoint); + } + + if (this.command != null) { + createCommand.withCmd(this.command); + } + + if (this.network != null) { + hostConfig.withNetworkMode(this.network.getId()); + createCommand.withAliases(this.networkAliases.toArray(new String[0])); + } else { + if (this.networkMode != null) { + createCommand.getHostConfig().withNetworkMode(this.networkMode); + } + } + + boolean shouldCheckFileMountingSupport = + this.binds.size() > 0 && !TestcontainersConfiguration.getInstance().isDisableChecks(); + if (shouldCheckFileMountingSupport) { + if (!DockerClientFactory.instance().isFileMountingSupported()) { + log.warn( + "Unable to mount a file from test host into a running container. " + + "This may be a misconfiguration or limitation of your Docker environment. " + + "Some features might not work." + ); + } + } + + hostConfig.withBinds(this.binds.toArray(new Bind[0])); + + if (this.privilegedMode) { + createCommand.getHostConfig().withPrivileged(this.privilegedMode); + } + + Map combinedLabels = new HashMap<>(this.labels); + if (createCommand.getLabels() != null) { + combinedLabels.putAll(createCommand.getLabels()); + } + + createCommand.withLabels(combinedLabels); + } + + protected void setImage(RemoteDockerImage image) { + this.image = image; + } + + protected void setImage(String image) { + setImage(DockerImageName.parse(image)); + } + + protected void setImage(DockerImageName image) { + setImage(new RemoteDockerImage(image)); + } + + public Set getExposedPorts() { + return new LinkedHashSet<>(this.exposedPorts); + } + + protected void setExposedPorts(Set exposedPorts) { + this.exposedPorts.clear(); + this.exposedPorts.addAll(exposedPorts); + } + + protected void addExposedPorts(ExposedPort... exposedPorts) { + this.exposedPorts.addAll(Arrays.asList(exposedPorts)); + } + + protected void addExposedPort(ExposedPort exposedPort) { + this.exposedPorts.add(exposedPort); + } + + protected void setExposedTcpPorts(Set ports) { + this.exposedPorts.clear(); + ports.forEach(port -> this.exposedPorts.add(ExposedPort.tcp(port))); + } + + protected void addExposedTcpPorts(int... ports) { + for (int port : ports) { + this.exposedPorts.add(ExposedPort.tcp(port)); + } + } + + protected void addExposedTcpPort(int port) { + this.exposedPorts.add(ExposedPort.tcp(port)); + } + + protected void addExposedPort(int port, InternetProtocol protocol) { + this.exposedPorts.add(new ExposedPort(port, protocol)); + } + + public Set getPortBindings() { + return new HashSet<>(this.portBindings); + } + + protected void setPortBindings(Set portBindings) { + this.portBindings.clear(); + this.portBindings.addAll(portBindings); + } + + protected void addPortBindings(PortBinding... portBindings) { + this.portBindings.addAll(Arrays.asList(portBindings)); + } + + protected void addPortBinding(PortBinding portBinding) { + this.portBindings.add(portBinding); + } + + public Map getLabels() { + return new HashMap<>(this.labels); + } + + protected void setLabels(Map labels) { + this.labels.clear(); + this.labels.putAll(labels); + } + + protected void addLabels(Map labels) { + this.labels.putAll(labels); + } + + protected void addLabel(String key, String value) { + this.labels.put(key, value); + } + + public Map getEnvVars() { + return new HashMap<>(this.envVars); + } + + protected void setEnvVars(Map envVars) { + this.envVars.clear(); + this.envVars.putAll(envVars); + } + + protected void addEnvVars(Map envVars) { + this.envVars.putAll(envVars); + } + + protected void addEnvVar(String key, String value) { + this.envVars.put(key, value); + } + + public String[] getEntrypoint() { + return Arrays.copyOf(this.entrypoint, this.entrypoint.length); + } + + protected void setEntrypoint(String... entrypoint) { + this.entrypoint = entrypoint; + } + + public String[] getCommand() { + return Arrays.copyOf(this.command, this.command.length); + } + + protected void setCommand(String... command) { + this.command = command; + } + + protected void setNetwork(Network network) { + this.network = network; + } + + public Set getNetworkAliases() { + return new LinkedHashSet<>(this.networkAliases); + } + + protected void setNetworkAliases(Set aliases) { + this.networkAliases.clear(); + this.networkAliases.addAll(aliases); + } + + protected void addNetworkAliases(String... aliases) { + this.networkAliases.addAll(Arrays.asList(aliases)); + } + + protected void addNetworkAlias(String alias) { + this.networkAliases.add(alias); + } + + protected void setNetworkMode(String networkMode) { + this.networkMode = networkMode; + } + + protected void setPrivilegedMode(boolean privilegedMode) { + this.privilegedMode = privilegedMode; + } + + public List getBinds() { + return new ArrayList<>(this.binds); + } + + protected void setBinds(List binds) { + this.binds.clear(); + this.binds.addAll(binds); + } + + protected void addBinds(Bind... binds) { + this.binds.addAll(Arrays.asList(binds)); + } + + protected void addBind(Bind bind) { + this.binds.add(bind); + } + + protected void setWaitStrategy(WaitStrategy waitStrategy) { + this.waitStrategy = waitStrategy; + } +} diff --git a/core/src/main/java/org/testcontainers/containers/ContainerFetchException.java b/core/src/main/java/org/testcontainers/containers/ContainerFetchException.java index 66aa1a493b0..66414434786 100644 --- a/core/src/main/java/org/testcontainers/containers/ContainerFetchException.java +++ b/core/src/main/java/org/testcontainers/containers/ContainerFetchException.java @@ -4,6 +4,7 @@ * Created by rnorth on 15/10/2015. */ public class ContainerFetchException extends RuntimeException { + public ContainerFetchException(String s, Exception e) { super(s, e); } diff --git a/core/src/main/java/org/testcontainers/containers/ContainerLaunchException.java b/core/src/main/java/org/testcontainers/containers/ContainerLaunchException.java index 9943d9f612b..bf407c16808 100644 --- a/core/src/main/java/org/testcontainers/containers/ContainerLaunchException.java +++ b/core/src/main/java/org/testcontainers/containers/ContainerLaunchException.java @@ -9,7 +9,7 @@ public ContainerLaunchException(String message) { super(message); } - public ContainerLaunchException(String message, Exception exception) { - super(message, exception); + public ContainerLaunchException(String message, Throwable cause) { + super(message, cause); } } diff --git a/core/src/main/java/org/testcontainers/containers/ContainerState.java b/core/src/main/java/org/testcontainers/containers/ContainerState.java new file mode 100644 index 00000000000..e19f7a85310 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/ContainerState.java @@ -0,0 +1,418 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.HealthState; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.exception.DockerException; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.PortBinding; +import com.github.dockerjava.api.model.Ports; +import com.google.common.base.Preconditions; +import lombok.SneakyThrows; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.utils.IOUtils; +import org.apache.commons.lang3.math.NumberUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.LogUtils; +import org.testcontainers.utility.MountableFile; +import org.testcontainers.utility.ThrowingFunction; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +public interface ContainerState { + String STATE_HEALTHY = "healthy"; + + /** + * Get the IP address that this container may be reached on (may not be the local machine). + * + * @return an IP address + * @deprecated use {@link #getHost()} + * @see #getHost() + */ + @Deprecated + default String getContainerIpAddress() { + return getHost(); + } + + default DockerClient getDockerClient() { + return DockerClientFactory.lazyClient(); + } + + /** + * Get the host that this container may be reached on (may not be the local machine). + * + * @return a host + */ + default String getHost() { + return DockerClientFactory.instance().dockerHostIpAddress(); + } + + /** + * @return is the container currently running? + */ + default boolean isRunning() { + if (getContainerId() == null) { + return false; + } + + try { + Boolean running = getCurrentContainerInfo().getState().getRunning(); + return Boolean.TRUE.equals(running); + } catch (DockerException e) { + return false; + } + } + + /** + * @return is the container created? + */ + default boolean isCreated() { + if (getContainerId() == null) { + return false; + } + + try { + String status = getCurrentContainerInfo().getState().getStatus(); + return "created".equalsIgnoreCase(status) || isRunning(); + } catch (DockerException e) { + return false; + } + } + + /** + * @return has the container health state 'healthy'? + */ + default boolean isHealthy() { + if (getContainerId() == null) { + return false; + } + + try { + InspectContainerResponse inspectContainerResponse = getCurrentContainerInfo(); + HealthState health = inspectContainerResponse.getState().getHealth(); + if (health == null) { + throw new RuntimeException( + "This container's image does not have a healthcheck declared, so health cannot be determined. Either amend the image or use another approach to determine whether containers are healthy." + ); + } + + return STATE_HEALTHY.equals(health.getStatus()); + } catch (DockerException e) { + return false; + } + } + + /** + * Inspects the container and returns up-to-date inspection response. + * + * @return up-to-date container inspect response + * @see #getContainerInfo() + */ + default InspectContainerResponse getCurrentContainerInfo() { + return getDockerClient().inspectContainerCmd(getContainerId()).exec(); + } + + /** + * Get the actual mapped port for a first port exposed by the container. + * Should be used in conjunction with {@link #getHost()}. + * + * @return the port that the exposed port is mapped to + * @throws IllegalStateException if there are no exposed ports + */ + default Integer getFirstMappedPort() { + return getExposedPorts() + .stream() + .findFirst() + .map(this::getMappedPort) + .orElseThrow(() -> new IllegalStateException("Container doesn't expose any ports")); + } + + /** + * Get the actual mapped port for a given port exposed by the container. + * It should be used in conjunction with {@link #getHost()}. + *

+ * Note: The returned port number might be outdated (for instance, after disconnecting from a network and reconnecting + * again). If you always need up-to-date value, override the {@link #getContainerInfo()} to return the + * {@link #getCurrentContainerInfo()}. + * + * @param originalPort the original TCP port that is exposed + * @return the port that the exposed port is mapped to, or null if it is not exposed + * @see #getContainerInfo() + * @see #getCurrentContainerInfo() + */ + default Integer getMappedPort(int originalPort) { + Preconditions.checkState( + this.getContainerId() != null, + "Mapped port can only be obtained after the container is started" + ); + + Ports.Binding[] binding = new Ports.Binding[0]; + final InspectContainerResponse containerInfo = this.getContainerInfo(); + if (containerInfo != null) { + binding = containerInfo.getNetworkSettings().getPorts().getBindings().get(new ExposedPort(originalPort)); + } + + if (binding != null && binding.length > 0 && binding[0] != null) { + return Integer.valueOf(binding[0].getHostPortSpec()); + } else { + throw new IllegalArgumentException("Requested port (" + originalPort + ") is not mapped"); + } + } + + /** + * @return the exposed ports + */ + List getExposedPorts(); + + /** + * @return the port bindings + */ + default List getPortBindings() { + List portBindings = new ArrayList<>(); + final Ports hostPortBindings = this.getContainerInfo().getHostConfig().getPortBindings(); + for (Map.Entry binding : hostPortBindings.getBindings().entrySet()) { + for (Ports.Binding portBinding : binding.getValue()) { + portBindings.add(String.format("%s:%s", portBinding.toString(), binding.getKey())); + } + } + return portBindings; + } + + /** + * @return the bound port numbers + */ + default List getBoundPortNumbers() { + return getPortBindings() + .stream() + .map(PortBinding::parse) + .map(PortBinding::getBinding) + .map(Ports.Binding::getHostPortSpec) + .filter(Objects::nonNull) + .filter(NumberUtils::isNumber) + .map(Integer::valueOf) + .filter(port -> port > 0) + .collect(Collectors.toList()); + } + + /** + * @return all log output from the container from start until the current instant (both stdout and stderr) + */ + default String getLogs() { + return LogUtils.getOutput(getDockerClient(), getContainerId()); + } + + /** + * @param types log types to return + * @return all log output from the container from start until the current instant + */ + default String getLogs(OutputFrame.OutputType... types) { + return LogUtils.getOutput(getDockerClient(), getContainerId(), types); + } + + /** + * @return the id of the container + */ + default String getContainerId() { + return getContainerInfo().getId(); + } + + /** + * Returns the container inspect response. The response might be cached/outdated. + * + * @return the container info + * @see #getCurrentContainerInfo() + */ + InspectContainerResponse getContainerInfo(); + + /** + * Run a command inside a running container, as though using "docker exec", and interpreting + * the output as UTF8. + *

+ * @see #execInContainer(Charset, String...) + */ + default Container.ExecResult execInContainer(String... command) + throws UnsupportedOperationException, IOException, InterruptedException { + return execInContainer(StandardCharsets.UTF_8, command); + } + + /** + * Run a command inside a running container, as though using "docker exec". + *

+ * @see ExecInContainerPattern#execInContainer(DockerClient, InspectContainerResponse, Charset, String...) + */ + default Container.ExecResult execInContainer(Charset outputCharset, String... command) + throws UnsupportedOperationException, IOException, InterruptedException { + return ExecInContainerPattern.execInContainer(getDockerClient(), getContainerInfo(), outputCharset, command); + } + + /** + * Run a command inside a running container as a given user, as using "docker exec -u user". + *

+ * @see ExecInContainerPattern#execInContainerWithUser(DockerClient, InspectContainerResponse, String, String...) + * @deprecated use {@link #execInContainer(ExecConfig)} + */ + @Deprecated + default Container.ExecResult execInContainerWithUser(String user, String... command) + throws UnsupportedOperationException, IOException, InterruptedException { + return ExecInContainerPattern.execInContainer( + getDockerClient(), + getContainerInfo(), + ExecConfig.builder().user(user).command(command).build() + ); + } + + /** + * Run a command inside a running container as a given user, as using "docker exec -u user". + *

+ * @see ExecInContainerPattern#execInContainerWithUser(DockerClient, InspectContainerResponse, Charset, String, String...) + * @deprecated use {@link #execInContainer(Charset, ExecConfig)} + */ + @Deprecated + default Container.ExecResult execInContainerWithUser(Charset outputCharset, String user, String... command) + throws UnsupportedOperationException, IOException, InterruptedException { + return ExecInContainerPattern.execInContainer( + getDockerClient(), + getContainerInfo(), + outputCharset, + ExecConfig.builder().user(user).command(command).build() + ); + } + + /** + * Run a command inside a running container, as though using "docker exec". + */ + default Container.ExecResult execInContainer(ExecConfig execConfig) + throws UnsupportedOperationException, IOException, InterruptedException { + return ExecInContainerPattern.execInContainer(getDockerClient(), getContainerInfo(), execConfig); + } + + /** + * Run a command inside a running container, as though using "docker exec". + */ + default Container.ExecResult execInContainer(Charset outputCharset, ExecConfig execConfig) + throws UnsupportedOperationException, IOException, InterruptedException { + return ExecInContainerPattern.execInContainer(getDockerClient(), getContainerInfo(), outputCharset, execConfig); + } + + /** + * + * Copies a file or directory to the container. + * + * @param mountableFile file or directory which is copied into the container + * @param containerPath destination path inside the container + */ + default void copyFileToContainer(MountableFile mountableFile, String containerPath) { + File sourceFile = new File(mountableFile.getResolvedPath()); + + if (containerPath.endsWith("/") && sourceFile.isFile()) { + final Logger logger = LoggerFactory.getLogger(GenericContainer.class); + logger.warn( + "folder-like containerPath in copyFileToContainer is deprecated, please explicitly specify a file path" + ); + copyFileToContainer((Transferable) mountableFile, containerPath + sourceFile.getName()); + } else { + copyFileToContainer((Transferable) mountableFile, containerPath); + } + } + + /** + * + * Copies a file to the container. + * + * @param transferable file which is copied into the container + * @param containerPath destination path inside the container + */ + @SneakyThrows({ IOException.class, InterruptedException.class }) + default void copyFileToContainer(Transferable transferable, String containerPath) { + if (getContainerId() == null) { + throw new IllegalStateException("copyFileToContainer can only be used with created / running container"); + } + + try ( + PipedOutputStream pipedOutputStream = new PipedOutputStream(); + PipedInputStream pipedInputStream = new PipedInputStream(pipedOutputStream); + TarArchiveOutputStream tarArchive = new TarArchiveOutputStream(pipedOutputStream) + ) { + Thread thread = new Thread(() -> { + try { + tarArchive.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); + tarArchive.setBigNumberMode(TarArchiveOutputStream.BIGNUMBER_POSIX); + + transferable.transferTo(tarArchive, containerPath); + } finally { + IOUtils.closeQuietly(tarArchive); + } + }); + + thread.start(); + + getDockerClient() + .copyArchiveToContainerCmd(getContainerId()) + .withTarInputStream(pipedInputStream) + .withRemotePath("/") + .exec(); + + thread.join(); + } + } + + /** + * Copies a file which resides inside the container to user defined directory + * + * @param containerPath path to file which is copied from container + * @param destinationPath destination path to which file is copied with file name + * @throws IOException if there's an issue communicating with Docker or receiving entry from TarArchiveInputStream + * @throws InterruptedException if the thread waiting for the response is interrupted + */ + default void copyFileFromContainer(String containerPath, String destinationPath) + throws IOException, InterruptedException { + copyFileFromContainer( + containerPath, + inputStream -> { + try (FileOutputStream output = new FileOutputStream(destinationPath)) { + IOUtils.copy(inputStream, output); + return null; + } + } + ); + } + + /** + * Streams a file which resides inside the container + * + * @param containerPath path to file which is copied from container + * @param function function that takes InputStream of the copied file + */ + @SneakyThrows + default T copyFileFromContainer(String containerPath, ThrowingFunction function) { + if (getContainerId() == null) { + throw new IllegalStateException("copyFileFromContainer can only be used when the Container is created."); + } + + DockerClient dockerClient = getDockerClient(); + try ( + InputStream inputStream = dockerClient.copyArchiveFromContainerCmd(getContainerId(), containerPath).exec(); + TarArchiveInputStream tarInputStream = new TarArchiveInputStream(inputStream) + ) { + tarInputStream.getNextTarEntry(); + return function.apply(tarInputStream); + } + } +} diff --git a/core/src/main/java/org/testcontainers/containers/ContainerisedDockerCompose.java b/core/src/main/java/org/testcontainers/containers/ContainerisedDockerCompose.java new file mode 100644 index 00000000000..7d6e60df29f --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/ContainerisedDockerCompose.java @@ -0,0 +1,118 @@ +package org.testcontainers.containers; + +import com.google.common.base.Joiner; +import com.google.common.util.concurrent.Uninterruptibles; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.SystemUtils; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.startupcheck.IndefiniteWaitOneShotStartupCheckStrategy; +import org.testcontainers.utility.AuditLogger; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; +import org.testcontainers.utility.PathUtils; + +import java.io.File; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * Use Docker Compose container. + */ +class ContainerisedDockerCompose extends GenericContainer implements DockerCompose { + + public static final char UNIX_PATH_SEPARATOR = ':'; + + public ContainerisedDockerCompose( + DockerImageName dockerImageName, + List composeFiles, + String identifier, + List fileCopyInclusions + ) { + super(dockerImageName); + addEnv(ENV_PROJECT_NAME, identifier); + + // Map the docker compose file into the container + final File dockerComposeBaseFile = composeFiles.get(0); + final String pwd = dockerComposeBaseFile.getAbsoluteFile().getParentFile().getAbsolutePath(); + final String containerPwd = convertToUnixFilesystemPath(pwd); + + final List absoluteDockerComposeFiles = composeFiles + .stream() + .map(File::getAbsolutePath) + .map(MountableFile::forHostPath) + .map(MountableFile::getFilesystemPath) + .map(this::convertToUnixFilesystemPath) + .collect(Collectors.toList()); + final String composeFileEnvVariableValue = Joiner.on(UNIX_PATH_SEPARATOR).join(absoluteDockerComposeFiles); // we always need the UNIX path separator + logger().debug("Set env COMPOSE_FILE={}", composeFileEnvVariableValue); + addEnv(ENV_COMPOSE_FILE, composeFileEnvVariableValue); + if (fileCopyInclusions.isEmpty()) { + logger().info("Copying all files in {} into the container", pwd); + withCopyFileToContainer(MountableFile.forHostPath(pwd), containerPwd); + } else { + // Always copy the compose file itself + logger().info("Copying docker compose file: {}", dockerComposeBaseFile.getAbsolutePath()); + withCopyFileToContainer( + MountableFile.forHostPath(dockerComposeBaseFile.getAbsolutePath()), + convertToUnixFilesystemPath(dockerComposeBaseFile.getAbsolutePath()) + ); + for (String pathToCopy : fileCopyInclusions) { + String hostPath = pwd + "/" + pathToCopy; + logger().info("Copying inclusion file: {}", hostPath); + withCopyFileToContainer(MountableFile.forHostPath(hostPath), convertToUnixFilesystemPath(hostPath)); + } + } + + // Ensure that compose can access docker. Since the container is assumed to be running on the same machine + // as the docker daemon, just mapping the docker control socket is OK. + // As there seems to be a problem with mapping to the /var/run directory in certain environments (e.g. CircleCI) + // we map the socket file outside of /var/run, as just /docker.sock + addFileSystemBind( + DockerClientFactory.instance().getRemoteDockerUnixSocketPath(), + "/docker.sock", + BindMode.READ_WRITE + ); + addEnv("DOCKER_HOST", "unix:///docker.sock"); + setStartupCheckStrategy(new IndefiniteWaitOneShotStartupCheckStrategy()); + setWorkingDirectory(containerPwd); + } + + @Override + public void invoke() { + super.start(); + + followOutput(new Slf4jLogConsumer(logger())); + + // wait for the compose container to stop, which should only happen after it has spawned all the service containers + logger().info("Docker Compose container is running for command: {}", Joiner.on(" ").join(getCommandParts())); + while (isRunning()) { + logger().trace("Compose container is still running"); + Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS); + } + + AuditLogger.doComposeLog(getCommandParts(), getEnv()); + + final Integer exitCode = getDockerClient() + .inspectContainerCmd(getContainerId()) + .exec() + .getState() + .getExitCode(); + + if (exitCode == null || exitCode != 0) { + throw new ContainerLaunchException( + "Containerised Docker Compose exited abnormally with code " + + exitCode + + " whilst running command: " + + StringUtils.join(getCommandParts(), ' ') + ); + } + + logger().info("Docker Compose has finished running"); + } + + private String convertToUnixFilesystemPath(String path) { + return SystemUtils.IS_OS_WINDOWS ? PathUtils.createMinGWPath(path).substring(1) : path; + } +} diff --git a/core/src/main/java/org/testcontainers/containers/DockerCompose.java b/core/src/main/java/org/testcontainers/containers/DockerCompose.java new file mode 100644 index 00000000000..82b8a6d5142 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/DockerCompose.java @@ -0,0 +1,15 @@ +package org.testcontainers.containers; + +import java.util.Map; + +interface DockerCompose { + String ENV_PROJECT_NAME = "COMPOSE_PROJECT_NAME"; + + String ENV_COMPOSE_FILE = "COMPOSE_FILE"; + + DockerCompose withCommand(String cmd); + + DockerCompose withEnv(Map env); + + void invoke(); +} diff --git a/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java b/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java index ecd23770db6..bfdaf169dd5 100644 --- a/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java +++ b/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java @@ -1,288 +1,268 @@ package org.testcontainers.containers; -import com.github.dockerjava.api.DockerClient; -import com.github.dockerjava.api.exception.DockerException; import com.github.dockerjava.api.model.Container; import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Joiner; -import com.google.common.base.Splitter; -import com.google.common.collect.Maps; -import com.google.common.util.concurrent.Uninterruptibles; -import org.apache.commons.lang.SystemUtils; -import org.junit.runner.Description; -import org.rnorth.ducttape.ratelimits.RateLimiter; -import org.rnorth.ducttape.ratelimits.RateLimiterBuilder; -import org.rnorth.ducttape.unreliables.Unreliables; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.slf4j.profiler.Profiler; -import org.testcontainers.DockerClientFactory; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.SystemUtils; import org.testcontainers.containers.output.OutputFrame; -import org.testcontainers.containers.output.Slf4jLogConsumer; -import org.testcontainers.containers.startupcheck.IndefiniteWaitOneShotStartupCheckStrategy; -import org.testcontainers.utility.*; -import org.zeroturnaround.exec.InvalidExitValueException; -import org.zeroturnaround.exec.ProcessExecutor; -import org.zeroturnaround.exec.stream.slf4j.Slf4jStream; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.utility.Base58; +import org.testcontainers.utility.DockerImageName; import java.io.File; -import java.util.*; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; - -import static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.base.Preconditions.checkNotNull; -import static org.testcontainers.containers.BindMode.READ_ONLY; -import static org.testcontainers.containers.BindMode.READ_WRITE; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; /** * Container which launches Docker Compose, for the purposes of launching a defined set of containers. */ -public class DockerComposeContainer> extends FailureDetectingExternalResource { +@Slf4j +public class DockerComposeContainer> implements Startable { + + private final Map scalingPreferences = new HashMap<>(); - /** - * Random identifier which will become part of spawned containers names, so we can shut them down - */ - private final String identifier; - private final Map ambassadorContainers = new HashMap<>(); - private final List composeFiles; - private Set spawnedContainerIds; - private Map scalingPreferences = new HashMap<>(); - private DockerClient dockerClient; private boolean localCompose; + private boolean pull = true; + + private boolean build = false; + + private Set options = new HashSet<>(); + private boolean tailChildContainers; private static final Object MUTEX = new Object(); + private List services = new ArrayList<>(); + /** * Properties that should be passed through to all Compose and ambassador containers (not * necessarily to containers that are spawned by Compose itself) */ private Map env = new HashMap<>(); - private static final RateLimiter AMBASSADOR_CREATION_RATE_LIMITER = RateLimiterBuilder - .newBuilder() - .withRate(6, TimeUnit.MINUTES) - .withConstantThroughput() - .build(); + private RemoveImages removeImages; - @Deprecated + private boolean removeVolumes = true; + + public static final String COMPOSE_EXECUTABLE = SystemUtils.IS_OS_WINDOWS ? "docker-compose.exe" : "docker-compose"; + + private final ComposeDelegate composeDelegate; + + private String project; + + private List filesInDirectory = new ArrayList<>(); + + /** + * Creates a new DockerComposeContainer using the specified Docker image and compose files. + * + * @param image The Docker image to use for the container + * @param composeFiles One or more Docker Compose configuration files + */ + public DockerComposeContainer(DockerImageName image, File... composeFiles) { + this(image, Arrays.asList(composeFiles)); + } + + /** + * Creates a new DockerComposeContainer using the specified Docker image and compose files. + * + * @param image The Docker image to use for the container + * @param composeFiles A list of Docker Compose configuration files + */ + public DockerComposeContainer(DockerImageName image, List composeFiles) { + this(image, Base58.randomString(6).toLowerCase(), composeFiles); + } + + /** + * Creates a new DockerComposeContainer with the specified Docker image, identifier, and compose files. + * + * @param image The Docker image to use for the container + * @param identifier A unique identifier for this compose environment + * @param composeFiles One or more Docker Compose configuration files + */ + public DockerComposeContainer(DockerImageName image, String identifier, File... composeFiles) { + this(image, identifier, Arrays.asList(composeFiles)); + } + + /** + * Creates a new DockerComposeContainer with the specified Docker image, identifier, and a single compose file. + * + * @param image The Docker image to use for the container + * @param identifier A unique identifier for this compose environment + * @param composeFile A Docker Compose configuration file + */ + public DockerComposeContainer(DockerImageName image, String identifier, File composeFile) { + this(image, identifier, Collections.singletonList(composeFile)); + } + + /** + * Creates a new DockerComposeContainer with the specified Docker image, identifier, and compose files. + * + * @param image The Docker image to use for the container + * @param identifier A unique identifier for this compose environment + * @param composeFiles A list of Docker Compose configuration files + */ + public DockerComposeContainer(DockerImageName image, String identifier, List composeFiles) { + this.composeDelegate = + new ComposeDelegate(ComposeDelegate.ComposeVersion.V1, composeFiles, identifier, COMPOSE_EXECUTABLE, image); + this.project = this.composeDelegate.getProject(); + } + + /** + * Use the new constructor {@link #DockerComposeContainer(DockerImageName image, String identifier, File composeFile)} + */ public DockerComposeContainer(File composeFile, String identifier) { this(identifier, composeFile); + this.localCompose = true; } + /** + * Use the new constructor {@link #DockerComposeContainer(DockerImageName image, List composeFiles)} + */ public DockerComposeContainer(File... composeFiles) { this(Arrays.asList(composeFiles)); + this.localCompose = true; } + /** + * Use the new constructor {@link #DockerComposeContainer(DockerImageName image, List composeFiles)} + */ + @Deprecated public DockerComposeContainer(List composeFiles) { this(Base58.randomString(6).toLowerCase(), composeFiles); + this.localCompose = true; } + /** + * Use the new constructor {@link #DockerComposeContainer(DockerImageName image, String identifier, File... composeFiles)} + */ public DockerComposeContainer(String identifier, File... composeFiles) { this(identifier, Arrays.asList(composeFiles)); + this.localCompose = true; } + /** + * Use the new constructor {@link #DockerComposeContainer(DockerImageName image, String identifier, List composeFiles)} + */ public DockerComposeContainer(String identifier, List composeFiles) { - - this.composeFiles = composeFiles; - - // Use a unique identifier so that containers created for this compose environment can be identified - this.identifier = identifier; - - this.dockerClient = DockerClientFactory.instance().client(); + this.composeDelegate = + new ComposeDelegate( + ComposeDelegate.ComposeVersion.V1, + composeFiles, + identifier, + COMPOSE_EXECUTABLE, + DockerImageName.parse("docker/compose:1.29.2") + ); + this.project = this.composeDelegate.getProject(); + this.localCompose = true; } @Override - @VisibleForTesting - public void starting(Description description) { - final Profiler profiler = new Profiler("Docker Compose container rule"); - profiler.setLogger(logger()); - profiler.start("Docker Compose container startup"); - + public void start() { synchronized (MUTEX) { + this.composeDelegate.registerContainersForShutdown(); if (pull) { - pullImages(); - } - applyScaling(); // scale before up, so that all scaled instances are available first for linking - createServices(); - if (tailChildContainers) { - tailChildContainerLogs(); - } - registerContainersForShutdown(); - startAmbassadorContainers(profiler); - } - } - - private void pullImages() { - getDockerCompose("pull") - .start(); - } - - - private void createServices() { - // Start the docker-compose container, which starts up the services - getDockerCompose("up -d") - .start(); - } - - private void tailChildContainerLogs() { - listChildContainers().forEach(container -> - LogUtils.followOutput(dockerClient, - container.getId(), - new Slf4jLogConsumer(logger()).withPrefix(container.getNames()[0]), - OutputFrame.OutputType.STDOUT, - OutputFrame.OutputType.STDERR) - ); - } - - private DockerCompose getDockerCompose(String cmd) { - final DockerCompose dockerCompose; - if (localCompose) { - dockerCompose = new LocalDockerCompose(composeFiles, identifier); - } else { - dockerCompose = new ContainerisedDockerCompose(composeFiles, identifier); - } - return dockerCompose - .withCommand(cmd) - .withEnv(env); - } - - private void applyScaling() { - // Apply scaling - if (!scalingPreferences.isEmpty()) { - StringBuilder sb = new StringBuilder("scale"); - for (Map.Entry scale : scalingPreferences.entrySet()) { - sb.append(" ").append(scale.getKey()).append("=").append(scale.getValue()); + try { + this.composeDelegate.pullImages(); + } catch (ContainerLaunchException e) { + log.warn("Exception while pulling images, using local images if available", e); + } } - - getDockerCompose(sb.toString()) - .start(); + this.composeDelegate.createServices( + this.localCompose, + this.build, + this.options, + this.services, + this.scalingPreferences, + this.env, + this.filesInDirectory + ); + this.composeDelegate.startAmbassadorContainer(); + this.composeDelegate.waitUntilServiceStarted(this.tailChildContainers); } } - private void registerContainersForShutdown() { - // Ensure that all service containers that were launched by compose will be killed at shutdown - try { - final List containers = listChildContainers(); - - // register with ResourceReaper to ensure final shutdown with JVM - containers.forEach(container -> - ResourceReaper.instance().registerContainerForCleanup(container.getId(), container.getNames()[0])); - - // Ensure that the default network for this compose environment, if any, is also cleaned up - ResourceReaper.instance().registerNetworkForCleanup(identifier + "_default"); - // Compose can define their own networks as well; ensure these are cleaned up - dockerClient.listNetworksCmd().exec().forEach(network -> { - if (network.getName().contains(identifier)) { - ResourceReaper.instance().registerNetworkForCleanup(network.getName()); - } - }); - - // remember the IDs to allow containers to be killed as soon as we reach stop() - spawnedContainerIds = containers.stream() - .map(Container::getId) - .collect(Collectors.toSet()); - - } catch (DockerException e) { - logger().debug("Failed to stop a service container with exception", e); - } + @VisibleForTesting + List listChildContainers() { + return this.composeDelegate.listChildContainers(); } - private List listChildContainers() { - return dockerClient.listContainersCmd() - .withShowAll(true) - .exec().stream() - .filter(container -> Arrays.stream(container.getNames()).anyMatch(name -> - name.startsWith("/" + identifier))) - .collect(Collectors.toList()); + public SELF withServices(@NonNull String... services) { + this.services = Arrays.asList(services); + return self(); } - private void startAmbassadorContainers(Profiler profiler) { - for (final Map.Entry address : ambassadorContainers.entrySet()) { - + @Override + public void stop() { + synchronized (MUTEX) { try { - // Start any ambassador containers we need - profiler.start("Ambassador container startup"); + this.composeDelegate.getAmbassadorContainer().stop(); - final AmbassadorContainer ambassadorContainer = address.getValue(); - Unreliables.retryUntilSuccess(120, TimeUnit.SECONDS, () -> { + // Kill the services using docker-compose + String cmd = ComposeCommand.getDownCommand(ComposeDelegate.ComposeVersion.V1, this.options); - AMBASSADOR_CREATION_RATE_LIMITER.doWhenReady(() -> { - Profiler localProfiler = profiler.startNested("Ambassador container: " + ambassadorContainer.getContainerName()); - - localProfiler.start("Start ambassador container"); - - ambassadorContainer.start(); - }); - - return null; - }); - } catch (Exception e) { - logger().warn("Exception during ambassador container startup!", e); + if (removeVolumes) { + cmd += " -v"; + } + if (removeImages != null) { + cmd += " --rmi " + removeImages.dockerRemoveImagesType(); + } + this.composeDelegate.runWithCompose(this.localCompose, cmd, this.env, this.filesInDirectory); } finally { - profiler.stop().log(); + this.composeDelegate.clear(); + this.project = this.composeDelegate.randomProjectId(); } } } - private Logger logger() { - return LoggerFactory.getLogger(DockerComposeContainer.class); + public SELF withExposedService(String serviceName, int servicePort) { + this.composeDelegate.withExposedService(serviceName, servicePort, Wait.defaultWaitStrategy()); + return self(); } - @Override @VisibleForTesting - public void finished(Description description) { - - - synchronized (MUTEX) { - // shut down all the ambassador containers - ambassadorContainers.forEach((String address, AmbassadorContainer container) -> container.stop()); - - // Kill the services using docker-compose - getDockerCompose("down -v") - .start(); - - // remove the networks before removing the containers - ResourceReaper.instance().removeNetworks(identifier); - - // kill the spawned service containers - spawnedContainerIds.forEach(id -> ResourceReaper.instance().stopAndRemoveContainer(id)); - spawnedContainerIds.clear(); - } + public DockerComposeContainer withExposedService(String serviceName, int instance, int servicePort) { + return withExposedService(serviceName + "_" + instance, servicePort); } - public SELF withExposedService(String serviceName, int servicePort) { - - if (! serviceName.matches(".*_[0-9]+")) { - serviceName += "_1"; // implicit first instance of this service - } - - /* - * For every service/port pair that needs to be exposed, we have to start an 'ambassador container'. - * - * The ambassador container's role is to link (within the Docker network) to one of the - * compose services, and proxy TCP network I/O out to a port that the ambassador container - * exposes. - * - * This avoids the need for the docker compose file to explicitly expose ports on all the - * services. - * - * {@link GenericContainer} should ensure that the ambassador container is on the same network - * as the rest of the compose environment. - */ - AmbassadorContainer ambassadorContainer = - new AmbassadorContainer<>(new FutureContainer(this.identifier + "_" + serviceName), serviceName, servicePort) - .withEnv(env); - - // Ambassador containers will all be started together after docker compose has started - ambassadorContainers.put(serviceName + ":" + servicePort, ambassadorContainer); + public DockerComposeContainer withExposedService( + String serviceName, + int instance, + int servicePort, + WaitStrategy waitStrategy + ) { + this.composeDelegate.withExposedService(serviceName + "_" + instance, servicePort, waitStrategy); + return self(); + } + public SELF withExposedService(String serviceName, int servicePort, @NonNull WaitStrategy waitStrategy) { + this.composeDelegate.withExposedService(serviceName, servicePort, waitStrategy); return self(); } - public DockerComposeContainer withExposedService(String serviceName, int instance, int servicePort) { - return withExposedService(serviceName + "_" + instance, servicePort); + /** + * Specify the {@link WaitStrategy} to use to determine if the container is ready. + * + * @param serviceName the name of the service to wait for + * @param waitStrategy the WaitStrategy to use + * @return this + * @see org.testcontainers.containers.wait.strategy.Wait#defaultWaitStrategy() + */ + public SELF waitingFor(String serviceName, @NonNull WaitStrategy waitStrategy) { + String serviceInstanceName = this.composeDelegate.getServiceInstanceName(serviceName); + this.composeDelegate.addWaitStrategy(serviceInstanceName, waitStrategy); + return self(); } /** @@ -296,7 +276,7 @@ public DockerComposeContainer withExposedService(String serviceName, int instanc * @return a host IP address or hostname that can be used for accessing the service container. */ public String getServiceHost(String serviceName, Integer servicePort) { - return ambassadorContainers.get(serviceName + ":" + servicePort).getContainerIpAddress(); + return this.composeDelegate.getServiceHost(); } /** @@ -310,12 +290,11 @@ public String getServiceHost(String serviceName, Integer servicePort) { * @return a port that can be used for accessing the service container. */ public Integer getServicePort(String serviceName, Integer servicePort) { - return ambassadorContainers.get(serviceName + ":" + servicePort).getMappedPort(servicePort); + return this.composeDelegate.getServicePort(serviceName, servicePort); } public SELF withScaledService(String serviceBaseName, int numInstances) { scalingPreferences.put(serviceBaseName, numInstances); - return self(); } @@ -329,16 +308,6 @@ public SELF withEnv(Map env) { return self(); } - /** - * Use a local Docker Compose binary instead of a container. - * - * @return this instance, for chaining - */ - public SELF withLocalCompose(boolean localCompose) { - this.localCompose = localCompose; - return self(); - } - /** * Whether to pull images first. * @@ -359,158 +328,107 @@ public SELF withTailChildContainers(boolean tailChildContainers) { return self(); } - private SELF self() { - return (SELF) this; + /** + * Attach an output consumer at container startup, enabling stdout and stderr to be followed, waited on, etc. + *

+ * More than one consumer may be registered. + * + * @param serviceName the name of the service as set in the docker-compose.yml file + * @param consumer consumer that output frames should be sent to + * @return this instance, for chaining + */ + public SELF withLogConsumer(String serviceName, Consumer consumer) { + this.composeDelegate.withLogConsumer(serviceName, consumer); + return self(); } -} - -interface DockerCompose { - String ENV_PROJECT_NAME = "COMPOSE_PROJECT_NAME"; - String ENV_COMPOSE_FILE = "COMPOSE_FILE"; - - DockerCompose withCommand(String cmd); - DockerCompose withEnv(Map env); - - void start(); - - default void validateFileList(List composeFiles) { - checkNotNull(composeFiles); - checkArgument(!composeFiles.isEmpty(), "No docker compose file have been provided"); + /** + * Whether to always build images before starting containers. + * + * @return this instance, for chaining + */ + public SELF withBuild(boolean build) { + this.build = build; + return self(); } -} - -/** - * Use Docker Compose container. - */ -class ContainerisedDockerCompose extends GenericContainer implements DockerCompose { - public ContainerisedDockerCompose(List composeFiles, String identifier) { - - super("docker/compose:1.8.0"); - validateFileList(composeFiles); - - addEnv(ENV_PROJECT_NAME, identifier); - - // Map the docker compose file into the container - final File dockerComposeBaseFile = composeFiles.get(0); - final String pwd = dockerComposeBaseFile.getAbsoluteFile().getParentFile().getAbsolutePath(); - final String containerPwd; - if (SystemUtils.IS_OS_WINDOWS) { - containerPwd = PathUtils.createMinGWPath(pwd).substring(1); - } else { - containerPwd = pwd; - } - final List absoluteDockerComposeFiles = composeFiles.stream().map( - file -> containerPwd + "/" + file.getAbsoluteFile().getName()).collect(Collectors.toList()); - final String composeFileEnvVariableValue = Joiner.on(File.pathSeparator).join(absoluteDockerComposeFiles); - logger().debug("Set env COMPOSE_FILE={}", composeFileEnvVariableValue); - addEnv(ENV_COMPOSE_FILE, composeFileEnvVariableValue); - addFileSystemBind(pwd, containerPwd, READ_ONLY); - - // Ensure that compose can access docker. Since the container is assumed to be running on the same machine - // as the docker daemon, just mapping the docker control socket is OK. - // As there seems to be a problem with mapping to the /var/run directory in certain environments (e.g. CircleCI) - // we map the socket file outside of /var/run, as just /docker.sock - addFileSystemBind("/var/run/docker.sock", "/docker.sock", READ_WRITE); - addEnv("DOCKER_HOST", "unix:///docker.sock"); - setStartupCheckStrategy(new IndefiniteWaitOneShotStartupCheckStrategy()); - setWorkingDirectory(containerPwd); + /** + * Adds options to the docker-compose command, e.g. docker-compose --compatibility. + * + * @return this instance, for chaining + */ + public SELF withOptions(String... options) { + this.options = new HashSet<>(Arrays.asList(options)); + return self(); } - @Override - public void start() { - super.start(); - - this.followOutput(new Slf4jLogConsumer(logger())); - - // wait for the compose container to stop, which should only happen after it has spawned all the service containers - logger().info("Docker Compose container is running for command: {}", Joiner.on(" ").join(this.getCommandParts())); - while (this.isRunning()) { - logger().trace("Compose container is still running"); - Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS); - } - logger().info("Docker Compose has finished running"); + /** + * Remove images after containers shutdown. + * + * @return this instance, for chaining + */ + public SELF withRemoveImages(RemoveImages removeImages) { + this.removeImages = removeImages; + return self(); } -} -/** - * Use local Docker Compose binary, if present. - */ -class LocalDockerCompose implements DockerCompose { /** - * Executable name for Docker Compose. + * Remove volumes after containers shut down. + * + * @param removeVolumes whether volumes are to be removed. + * @return this instance, for chaining. */ - private static final String COMPOSE_EXECUTABLE = "docker-compose"; - - private final List composeFiles; - private final String identifier; - private String cmd = ""; - private Map env = new HashMap<>(); - - public LocalDockerCompose(List composeFiles, String identifier) { - validateFileList(composeFiles); - - this.composeFiles = composeFiles; - this.identifier = identifier; + public SELF withRemoveVolumes(boolean removeVolumes) { + this.removeVolumes = removeVolumes; + return self(); } - @Override - public DockerCompose withCommand(String cmd) { - this.cmd = cmd; - return this; + /** + * Set the maximum startup timeout all the waits set are bounded to. + * + * @return this instance. for chaining + */ + public SELF withStartupTimeout(Duration startupTimeout) { + this.composeDelegate.setStartupTimeout(startupTimeout); + return self(); } - @Override - public DockerCompose withEnv(Map env) { - this.env = env; - return this; + public SELF withCopyFilesInContainer(String... fileCopyInclusions) { + this.filesInDirectory = Arrays.asList(fileCopyInclusions); + return self(); } - @Override - public void start() { - // bail out early - if (!CommandLine.executableExists(COMPOSE_EXECUTABLE)) { - throw new ContainerLaunchException("Local Docker Compose not found. Is " + COMPOSE_EXECUTABLE + " on the PATH?"); - } - - final Map environment = Maps.newHashMap(env); - environment.put(ENV_PROJECT_NAME, identifier); - - final File dockerComposeBaseFile = composeFiles.get(0); - final File pwd = dockerComposeBaseFile.getAbsoluteFile().getParentFile().getAbsoluteFile(); - environment.put(ENV_COMPOSE_FILE, new File(pwd, dockerComposeBaseFile.getAbsoluteFile().getName()).getAbsolutePath()); + public Optional getContainerByServiceName(String serviceName) { + return this.composeDelegate.getContainerByServiceName(serviceName); + } - logger().info("Local Docker Compose is running command: {}", cmd); + private void followLogs(String containerId, Consumer consumer) { + this.followLogs(containerId, consumer); + } - final List command = Splitter.onPattern(" ") - .omitEmptyStrings() - .splitToList(COMPOSE_EXECUTABLE + " " + cmd); + private SELF self() { + return (SELF) this; + } - try { - new ProcessExecutor().command(command) - .redirectOutput(Slf4jStream.of(logger()).asInfo()) - .redirectError(Slf4jStream.of(logger()).asError()) - .environment(environment) - .directory(pwd) - .exitValueNormal() - .executeNoTimeout(); + public enum RemoveImages { + /** + * Remove all images used by any service. + */ + ALL("all"), - logger().info("Docker Compose has finished running"); + /** + * Remove only images that don't have a custom tag set by the `image` field. + */ + LOCAL("local"); - } catch (InvalidExitValueException e) { - throw new ContainerLaunchException("Local Docker Compose exited abnormally with code " + - e.getExitValue() + " whilst running command: " + cmd); + private final String dockerRemoveImagesType; - } catch (Exception e) { - throw new ContainerLaunchException("Error running local Docker Compose command: " + cmd, e); + RemoveImages(final String dockerRemoveImagesType) { + this.dockerRemoveImagesType = dockerRemoveImagesType; } - } - /** - * @return a logger - */ - private Logger logger() { - return DockerLoggerFactory.getLogger(COMPOSE_EXECUTABLE); + public String dockerRemoveImagesType() { + return dockerRemoveImagesType; + } } } diff --git a/core/src/main/java/org/testcontainers/containers/DockerComposeFiles.java b/core/src/main/java/org/testcontainers/containers/DockerComposeFiles.java new file mode 100644 index 00000000000..b96ebfb005e --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/DockerComposeFiles.java @@ -0,0 +1,45 @@ +package org.testcontainers.containers; + +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public class DockerComposeFiles { + + private final List parsedComposeFiles; + + public DockerComposeFiles(List composeFiles) { + this.parsedComposeFiles = composeFiles.stream().map(ParsedDockerComposeFile::new).collect(Collectors.toList()); + } + + public Set getDependencyImages() { + Map> mergedServiceNameToImageNames = mergeServiceDependencyImageNames(); + + return getImageNames(mergedServiceNameToImageNames); + } + + private Map> mergeServiceDependencyImageNames() { + Map> mergedServiceNameToImageNames = new HashMap<>(); + for (ParsedDockerComposeFile parsedComposeFile : parsedComposeFiles) { + mergedServiceNameToImageNames.putAll(parsedComposeFile.getServiceNameToImageNames()); + } + return mergedServiceNameToImageNames; + } + + private Set getImageNames(Map> serviceToImageNames) { + return serviceToImageNames + .values() + .stream() + .flatMap(Collection::stream) + // Pass through DockerImageName to convert image names to canonical form (e.g. making implicit latest tag explicit) + .map(DockerImageName::parse) + .map(DockerImageName::asCanonicalNameString) + .collect(Collectors.toSet()); + } +} diff --git a/core/src/main/java/org/testcontainers/containers/DockerMcpGatewayContainer.java b/core/src/main/java/org/testcontainers/containers/DockerMcpGatewayContainer.java new file mode 100644 index 00000000000..40e80ae2061 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/DockerMcpGatewayContainer.java @@ -0,0 +1,105 @@ +package org.testcontainers.containers; + +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Testcontainers implementation of the Docker MCP Gateway container. + *

+ * Supported images: {@code docker/mcp-gateway} + *

+ * Exposed ports: 8811 + */ +public class DockerMcpGatewayContainer extends GenericContainer { + + private static final String DOCKER_MCP_GATEWAY_IMAGE = "docker/mcp-gateway"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse(DOCKER_MCP_GATEWAY_IMAGE); + + private static final int DEFAULT_PORT = 8811; + + private static final String SECRETS_PATH = "/testcontainers/app/secrets"; + + private final List servers = new ArrayList<>(); + + private final List tools = new ArrayList<>(); + + private final Map secrets = new HashMap<>(); + + public DockerMcpGatewayContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public DockerMcpGatewayContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withExposedPorts(DEFAULT_PORT); + withFileSystemBind(DockerClientFactory.instance().getRemoteDockerUnixSocketPath(), "/var/run/docker.sock"); + waitingFor(Wait.forLogMessage(".*Start sse server on port.*", 1)); + } + + @Override + protected void configure() { + List command = new ArrayList<>(); + command.add("--transport=sse"); + for (String server : this.servers) { + if (!server.isEmpty()) { + command.add("--servers=" + server); + } + } + for (String tool : this.tools) { + if (!tool.isEmpty()) { + command.add("--tools=" + tool); + } + } + if (this.secrets != null && !this.secrets.isEmpty()) { + command.add("--secrets=" + SECRETS_PATH); + } + withCommand(String.join(" ", command)); + } + + @Override + protected void containerIsCreated(String containerId) { + if (this.secrets != null && !this.secrets.isEmpty()) { + StringBuilder secretsFile = new StringBuilder(); + for (Map.Entry entry : this.secrets.entrySet()) { + secretsFile.append(entry.getKey()).append("=").append(entry.getValue()).append("\n"); + } + copyFileToContainer(Transferable.of(secretsFile.toString()), SECRETS_PATH); + } + } + + public DockerMcpGatewayContainer withServer(String server, List tools) { + this.servers.add(server); + this.tools.addAll(tools); + return this; + } + + public DockerMcpGatewayContainer withServer(String server, String... tools) { + this.servers.add(server); + this.tools.addAll(Arrays.asList(tools)); + return this; + } + + public DockerMcpGatewayContainer withSecrets(Map secrets) { + this.secrets.putAll(secrets); + return this; + } + + public DockerMcpGatewayContainer withSecret(String secretKey, String secretValue) { + this.secrets.put(secretKey, secretValue); + return this; + } + + public String getEndpoint() { + return "http://" + getHost() + ":" + getMappedPort(DEFAULT_PORT); + } +} diff --git a/core/src/main/java/org/testcontainers/containers/DockerModelRunnerContainer.java b/core/src/main/java/org/testcontainers/containers/DockerModelRunnerContainer.java new file mode 100644 index 00000000000..dde714a9bd2 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/DockerModelRunnerContainer.java @@ -0,0 +1,99 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +/** + * Testcontainers proxy container for the Docker Model Runner service + * provided by Docker Desktop. + *

+ * Supported images: {@code alpine/socat} + *

+ * Exposed ports: 80 + */ +@Slf4j +public class DockerModelRunnerContainer extends SocatContainer { + + private static final String MODEL_RUNNER_ENDPOINT = "model-runner.docker.internal"; + + private static final int PORT = 80; + + private String model; + + public DockerModelRunnerContainer(String image) { + this(DockerImageName.parse(image)); + } + + public DockerModelRunnerContainer(DockerImageName image) { + super(image); + withTarget(PORT, MODEL_RUNNER_ENDPOINT); + waitingFor(Wait.forHttp("/").forResponsePredicate(res -> res.contains("The service is running"))); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + if (this.model != null) { + logger().info("Pulling model: {}. Please be patient.", this.model); + + String url = getBaseEndpoint() + "/models/create"; + String payload = String.format("{\"from\": \"%s\"}", this.model); + + try { + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setDoOutput(true); + + try (OutputStream os = connection.getOutputStream()) { + os.write(payload.getBytes()); + os.flush(); + } + + try ( + BufferedReader br = new BufferedReader( + new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8) + ) + ) { + while (br.readLine() != null) {} + } + connection.disconnect(); + } catch (IOException e) { + logger().error("Failed to pull model {}: {}", this.model, e); + } + logger().info("Finished pulling model: {}", this.model); + } + } + + public DockerModelRunnerContainer withModel(String model) { + this.model = model; + return this; + } + + /** + * Returns the base endpoint URL for the Docker Model Runner service. + * + * @return the base URL in the format {@code http://:} + */ + public String getBaseEndpoint() { + return "http://" + getHost() + ":" + getMappedPort(PORT); + } + + /** + * Returns the OpenAI-compatible API endpoint URL for the Docker Model Runner service. + * + * @return the OpenAI-compatible endpoint URL in the format {@code http://:/engines} + */ + public String getOpenAIEndpoint() { + return getBaseEndpoint() + "/engines"; + } +} diff --git a/core/src/main/java/org/testcontainers/containers/ExecConfig.java b/core/src/main/java/org/testcontainers/containers/ExecConfig.java new file mode 100644 index 00000000000..935f242d369 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/ExecConfig.java @@ -0,0 +1,34 @@ +package org.testcontainers.containers; + +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; + +/** + * Exec configuration. + */ +@Builder +@Getter +public class ExecConfig { + + /** + * The command to run. + */ + private String[] command; + + /** + * The user to run the exec process. + */ + private String user; + + /** + * Key-value pairs of environment variables. + */ + private Map envVars; + + /** + * The working directory for the exec process. + */ + private String workDir; +} diff --git a/core/src/main/java/org/testcontainers/containers/ExecInContainerPattern.java b/core/src/main/java/org/testcontainers/containers/ExecInContainerPattern.java new file mode 100644 index 00000000000..1ed23224e5e --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/ExecInContainerPattern.java @@ -0,0 +1,263 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.ExecCreateCmd; +import com.github.dockerjava.api.command.ExecCreateCmdResponse; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.exception.DockerException; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.output.FrameConsumerResultCallback; +import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.containers.output.ToStringConsumer; +import org.testcontainers.utility.TestEnvironment; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Provides utility methods for executing commands in containers + */ +@UtilityClass +@Slf4j +public class ExecInContainerPattern { + + /** + * + * @deprecated use {@link #execInContainer(DockerClient, InspectContainerResponse, String...)} + */ + @Deprecated + public Container.ExecResult execInContainer(InspectContainerResponse containerInfo, String... command) + throws UnsupportedOperationException, IOException, InterruptedException { + DockerClient dockerClient = DockerClientFactory.instance().client(); + return execInContainer(dockerClient, containerInfo, command); + } + + /** + * + * @deprecated use {@link #execInContainer(DockerClient, InspectContainerResponse, Charset, String...)} + */ + @Deprecated + public Container.ExecResult execInContainer( + InspectContainerResponse containerInfo, + Charset outputCharset, + String... command + ) throws UnsupportedOperationException, IOException, InterruptedException { + DockerClient dockerClient = DockerClientFactory.instance().client(); + return execInContainerWithUser(dockerClient, containerInfo, outputCharset, null, command); + } + + /** + * Run a command inside a running container, as though using "docker exec", and interpreting + * the output as UTF8. + *

+ * @param dockerClient the {@link DockerClient} + * @param containerInfo the container info + * @param command the command to execute + * @see #execInContainerWithUser(DockerClient, InspectContainerResponse, String, String...) + */ + public Container.ExecResult execInContainer( + DockerClient dockerClient, + InspectContainerResponse containerInfo, + String... command + ) throws UnsupportedOperationException, IOException, InterruptedException { + return execInContainerWithUser(dockerClient, containerInfo, StandardCharsets.UTF_8, null, command); + } + + /** + * Run a command inside a running container, as though using "docker exec", and interpreting + * the output as UTF8. + *

+ * @param dockerClient the {@link DockerClient} + * @param containerInfo the container info + * @param outputCharset the character set used to interpret the output. + * @param command the command to execute + * @see #execInContainerWithUser(DockerClient, InspectContainerResponse, Charset, String, String...) + */ + public Container.ExecResult execInContainer( + DockerClient dockerClient, + InspectContainerResponse containerInfo, + Charset outputCharset, + String... command + ) throws UnsupportedOperationException, IOException, InterruptedException { + return execInContainerWithUser(dockerClient, containerInfo, outputCharset, null, command); + } + + /** + * Run a command inside a running container as a given user, as using "docker exec -u user" and + * interpreting the output as UTF8. + *

+ * This functionality is not available on a docker daemon running the older "lxc" execution driver. At + * the time of writing, CircleCI was using this driver. + * @param dockerClient the {@link DockerClient} + * @param containerInfo the container info + * @param user the user to run the command with, optional + * @param command the command to execute + * @see #execInContainerWithUser(DockerClient, InspectContainerResponse, Charset, String, + * String...) + * @deprecated use {@link #execInContainer(DockerClient, InspectContainerResponse, ExecConfig)} + */ + @Deprecated + public Container.ExecResult execInContainerWithUser( + DockerClient dockerClient, + InspectContainerResponse containerInfo, + String user, + String... command + ) throws UnsupportedOperationException, IOException, InterruptedException { + return execInContainerWithUser(dockerClient, containerInfo, StandardCharsets.UTF_8, user, command); + } + + /** + * Run a command inside a running container as a given user, as using "docker exec -u user". + *

+ * This functionality is not available on a docker daemon running the older "lxc" execution + * driver. At the time of writing, CircleCI was using this driver. + * @param dockerClient the {@link DockerClient} + * @param containerInfo the container info + * @param outputCharset the character set used to interpret the output. + * @param user the user to run the command with, optional + * @param command the parts of the command to run + * @return the result of execution + * @throws IOException if there's an issue communicating with Docker + * @throws InterruptedException if the thread waiting for the response is interrupted + * @throws UnsupportedOperationException if the docker daemon you're connecting to doesn't support "exec". + * @deprecated use {@link #execInContainer(DockerClient, InspectContainerResponse, Charset, ExecConfig)} + */ + @Deprecated + public Container.ExecResult execInContainerWithUser( + DockerClient dockerClient, + InspectContainerResponse containerInfo, + Charset outputCharset, + String user, + String... command + ) throws UnsupportedOperationException, IOException, InterruptedException { + return execInContainer( + dockerClient, + containerInfo, + outputCharset, + ExecConfig.builder().user(user).command(command).build() + ); + } + + /** + * Run a command inside a running container as a given user, as using "docker exec -u user". + *

+ * This functionality is not available on a docker daemon running the older "lxc" execution + * driver. At the time of writing, CircleCI was using this driver. + * @param dockerClient the {@link DockerClient} + * @param containerInfo the container info + * @param execConfig the exec configuration + * @return the result of execution + * @throws IOException if there's an issue communicating with Docker + * @throws InterruptedException if the thread waiting for the response is interrupted + * @throws UnsupportedOperationException if the docker daemon you're connecting to doesn't support "exec". + */ + public Container.ExecResult execInContainer( + DockerClient dockerClient, + InspectContainerResponse containerInfo, + ExecConfig execConfig + ) throws UnsupportedOperationException, IOException, InterruptedException { + return execInContainer(dockerClient, containerInfo, StandardCharsets.UTF_8, execConfig); + } + + /** + * Run a command inside a running container as a given user, as using "docker exec -u user". + *

+ * This functionality is not available on a docker daemon running the older "lxc" execution + * driver. At the time of writing, CircleCI was using this driver. + * @param dockerClient the {@link DockerClient} + * @param containerInfo the container info + * @param outputCharset the character set used to interpret the output. + * @param execConfig the exec configuration + * @return the result of execution + * @throws IOException if there's an issue communicating with Docker + * @throws InterruptedException if the thread waiting for the response is interrupted + * @throws UnsupportedOperationException if the docker daemon you're connecting to doesn't support "exec". + */ + public Container.ExecResult execInContainer( + DockerClient dockerClient, + InspectContainerResponse containerInfo, + Charset outputCharset, + ExecConfig execConfig + ) throws UnsupportedOperationException, IOException, InterruptedException { + if (!TestEnvironment.dockerExecutionDriverSupportsExec()) { + // at time of writing, this is the expected result in CircleCI. + throw new UnsupportedOperationException( + "Your docker daemon is running the \"lxc\" driver, which doesn't support \"docker exec\"." + ); + } + + if (!isRunning(containerInfo)) { + throw new IllegalStateException("execInContainer can only be used while the Container is running"); + } + + String containerId = containerInfo.getId(); + String containerName = containerInfo.getName(); + + String[] command = execConfig.getCommand(); + log.debug("{}: Running \"exec\" command: {}", containerName, String.join(" ", command)); + final ExecCreateCmd execCreateCmd = dockerClient + .execCreateCmd(containerId) + .withAttachStdout(true) + .withAttachStderr(true) + .withCmd(command); + + String user = execConfig.getUser(); + if (user != null && !user.isEmpty()) { + log.debug("{}: Running \"exec\" command with user: {}", containerName, user); + execCreateCmd.withUser(user); + } + + String workDir = execConfig.getWorkDir(); + if (workDir != null && !workDir.isEmpty()) { + log.debug("{}: Running \"exec\" command inside workingDir: {}", containerName, workDir); + execCreateCmd.withWorkingDir(workDir); + } + + Map envVars = execConfig.getEnvVars(); + if (envVars != null && !envVars.isEmpty()) { + List envVarList = envVars + .entrySet() + .stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.toList()); + execCreateCmd.withEnv(envVarList); + } + + final ExecCreateCmdResponse execCreateCmdResponse = execCreateCmd.exec(); + + final ToStringConsumer stdoutConsumer = new ToStringConsumer(); + final ToStringConsumer stderrConsumer = new ToStringConsumer(); + + try (FrameConsumerResultCallback callback = new FrameConsumerResultCallback()) { + callback.addConsumer(OutputFrame.OutputType.STDOUT, stdoutConsumer); + callback.addConsumer(OutputFrame.OutputType.STDERR, stderrConsumer); + + dockerClient.execStartCmd(execCreateCmdResponse.getId()).exec(callback).awaitCompletion(); + } + int exitCode = dockerClient.inspectExecCmd(execCreateCmdResponse.getId()).exec().getExitCodeLong().intValue(); + + final Container.ExecResult result = new Container.ExecResult( + exitCode, + stdoutConsumer.toString(outputCharset), + stderrConsumer.toString(outputCharset) + ); + + log.trace("{}: stdout: {}", containerName, result.getStdout()); + log.trace("{}: stderr: {}", containerName, result.getStderr()); + return result; + } + + private boolean isRunning(InspectContainerResponse containerInfo) { + try { + return containerInfo != null && containerInfo.getState().getRunning(); + } catch (DockerException e) { + return false; + } + } +} diff --git a/core/src/main/java/org/testcontainers/containers/FailureDetectingExternalResource.java b/core/src/main/java/org/testcontainers/containers/FailureDetectingExternalResource.java deleted file mode 100644 index cb103b04278..00000000000 --- a/core/src/main/java/org/testcontainers/containers/FailureDetectingExternalResource.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.testcontainers.containers; - -import org.junit.rules.TestRule; -import org.junit.runner.Description; -import org.junit.runners.model.MultipleFailureException; -import org.junit.runners.model.Statement; - -import java.util.ArrayList; -import java.util.List; - -/** - * {@link TestRule} which is called before and after each test, and also is notified on success/failure. - * - * This mimics the behaviour of TestWatcher to some degree, but failures occurring in this rule do not - * contribute to the overall failure count (which can otherwise cause strange negative test success - * figures). - */ -public class FailureDetectingExternalResource implements TestRule { - @Override - public Statement apply(Statement base, Description description) { - - return new Statement() { - @Override - public void evaluate() throws Throwable { - - List errors = new ArrayList(); - - starting(description); - - try { - base.evaluate(); - succeeded(description); - } catch (Throwable e) { - errors.add(e); - failed(e, description); - } finally { - finished(description); - } - - MultipleFailureException.assertEmpty(errors); - } - }; - } - - protected void starting(Description description) { - - } - - protected void succeeded(Description description) { - } - - protected void failed(Throwable e, Description description) { - } - - protected void finished(Description description) { - } -} diff --git a/core/src/main/java/org/testcontainers/containers/FixedHostPortGenericContainer.java b/core/src/main/java/org/testcontainers/containers/FixedHostPortGenericContainer.java index 83c749c8624..db610ad57c2 100644 --- a/core/src/main/java/org/testcontainers/containers/FixedHostPortGenericContainer.java +++ b/core/src/main/java/org/testcontainers/containers/FixedHostPortGenericContainer.java @@ -11,20 +11,36 @@ *

Callers are responsible for ensuring that this fixed port is actually available; failure will occur if it is * not available - which could manifest as flaky or unstable tests.

*/ -public class FixedHostPortGenericContainer> extends GenericContainer { +public class FixedHostPortGenericContainer> + extends GenericContainer { + + /** + * @deprecated it is highly recommended that {@link FixedHostPortGenericContainer} not be used, as it risks port conflicts. + */ + @Deprecated public FixedHostPortGenericContainer(@NotNull String dockerImageName) { super(dockerImageName); } /** - * Bind a fixed port on the docker host to a container port + * Bind a fixed TCP port on the docker host to a container port * @param hostPort a port on the docker host, which must be available * @param containerPort a port in the container * @return this container */ public SELF withFixedExposedPort(int hostPort, int containerPort) { + return withFixedExposedPort(hostPort, containerPort, InternetProtocol.TCP); + } - super.addFixedExposedPort(hostPort, containerPort); + /** + * Bind a fixed port on the docker host to a container port + * @param hostPort a port on the docker host, which must be available + * @param containerPort a port in the container + * @param protocol an internet protocol (tcp or udp) + * @return this container + */ + public SELF withFixedExposedPort(int hostPort, int containerPort, InternetProtocol protocol) { + super.addFixedExposedPort(hostPort, containerPort, protocol); return self(); } diff --git a/core/src/main/java/org/testcontainers/containers/FutureContainer.java b/core/src/main/java/org/testcontainers/containers/FutureContainer.java index b2269b534c1..28cfbfff6c9 100644 --- a/core/src/main/java/org/testcontainers/containers/FutureContainer.java +++ b/core/src/main/java/org/testcontainers/containers/FutureContainer.java @@ -8,5 +8,6 @@ */ @Data public class FutureContainer implements LinkableContainer { + private final String containerName; } diff --git a/core/src/main/java/org/testcontainers/containers/GenericContainer.java b/core/src/main/java/org/testcontainers/containers/GenericContainer.java index 167ea5864b9..4d3778c63d1 100644 --- a/core/src/main/java/org/testcontainers/containers/GenericContainer.java +++ b/core/src/main/java/org/testcontainers/containers/GenericContainer.java @@ -1,101 +1,134 @@ package org.testcontainers.containers; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.SerializationFeature; import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.command.CreateContainerCmd; -import com.github.dockerjava.api.command.ExecCreateCmdResponse; import com.github.dockerjava.api.command.InspectContainerResponse; -import com.github.dockerjava.api.exception.DockerException; -import com.github.dockerjava.api.model.*; -import com.google.common.base.Preconditions; +import com.github.dockerjava.api.exception.NotFoundException; +import com.github.dockerjava.api.model.Bind; +import com.github.dockerjava.api.model.ContainerNetwork; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.HostConfig; +import com.github.dockerjava.api.model.Link; +import com.github.dockerjava.api.model.PortBinding; +import com.github.dockerjava.api.model.Ports; +import com.github.dockerjava.api.model.Volume; +import com.github.dockerjava.api.model.VolumesFrom; +import com.github.dockerjava.core.DefaultDockerClientConfig; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.google.common.hash.Hashing; +import lombok.AccessLevel; import lombok.Data; -import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NonNull; +import lombok.Setter; +import lombok.SneakyThrows; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.SystemUtils; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.junit.runner.Description; -import org.rnorth.ducttape.ratelimits.RateLimiter; -import org.rnorth.ducttape.ratelimits.RateLimiterBuilder; import org.rnorth.ducttape.unreliables.Unreliables; import org.slf4j.Logger; -import org.slf4j.profiler.Profiler; import org.testcontainers.DockerClientFactory; -import org.testcontainers.containers.output.FrameConsumerResultCallback; +import org.testcontainers.UnstableAPI; import org.testcontainers.containers.output.OutputFrame; -import org.testcontainers.containers.output.Slf4jLogConsumer; -import org.testcontainers.containers.output.ToStringConsumer; import org.testcontainers.containers.startupcheck.IsRunningStartupCheckStrategy; import org.testcontainers.containers.startupcheck.MinimumDurationRunningStartupCheckStrategy; import org.testcontainers.containers.startupcheck.StartupCheckStrategy; import org.testcontainers.containers.traits.LinkableContainer; -import org.testcontainers.containers.wait.Wait; -import org.testcontainers.containers.wait.WaitStrategy; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; +import org.testcontainers.core.CreateContainerCmdModifier; +import org.testcontainers.images.ImagePullPolicy; import org.testcontainers.images.RemoteDockerImage; -import org.testcontainers.utility.*; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.lifecycle.Startables; +import org.testcontainers.utility.Base58; +import org.testcontainers.utility.CommandLine; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.DockerLoggerFactory; +import org.testcontainers.utility.DockerMachineClient; +import org.testcontainers.utility.DynamicPollInterval; +import org.testcontainers.utility.MountableFile; +import org.testcontainers.utility.PathUtils; +import org.testcontainers.utility.ResourceReaper; +import org.testcontainers.utility.TestcontainersConfiguration; import java.io.File; -import java.io.IOException; -import java.nio.charset.Charset; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.UndeclaredThrowableException; import java.nio.file.Path; +import java.nio.file.Paths; import java.time.Duration; -import java.util.*; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Optional; +import java.util.ServiceLoader; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.zip.Adler32; +import java.util.zip.Checksum; -import static java.util.Arrays.asList; -import static org.testcontainers.containers.output.OutputFrame.OutputType.STDERR; -import static org.testcontainers.containers.output.OutputFrame.OutputType.STDOUT; -import static org.testcontainers.utility.CommandLine.runShellCommand; +import static org.awaitility.Awaitility.await; /** * Base class for that allows a container to be launched and controlled. */ @Data -@EqualsAndHashCode(callSuper = false) public class GenericContainer> - extends FailureDetectingExternalResource - implements Container, AutoCloseable { - - private static final Charset UTF8 = Charset.forName("UTF-8"); + implements Container, AutoCloseable, WaitStrategyTarget, Startable { public static final int CONTAINER_RUNNING_TIMEOUT_SEC = 30; + public static final String INTERNAL_HOST_HOSTNAME = "host.testcontainers.internal"; + + static final String HASH_LABEL = "org.testcontainers.hash"; + + static final String COPIED_FILES_HASH_LABEL = "org.testcontainers.copied_files.hash"; + /* * Default settings */ - @NonNull - private List exposedPorts = new ArrayList<>(); - - @NonNull - private List portBindings = new ArrayList<>(); - @NonNull private List extraHosts = new ArrayList<>(); @NonNull - private String networkMode; - - @NonNull - private Future image; - - @NonNull - private List env = new ArrayList<>(); - - @NonNull - private String[] commandParts = new String[0]; - - @NonNull - private List binds = new ArrayList<>(); - - @NonNull - private boolean privilegedMode; + private RemoteDockerImage image; @NonNull private List volumesFroms = new ArrayList<>(); + /** + * @deprecated Links are deprecated (see #465). Please use {@link Network} features instead. + */ @NonNull + @Deprecated private Map linkedContainers = new HashMap<>(); private StartupCheckStrategy startupCheckStrategy = new IsRunningStartupCheckStrategy(); @@ -105,168 +138,523 @@ public class GenericContainer> @Nullable private String workingDirectory = null; - /* - * Unique instance of DockerClient for use by this container object. + /** + * The shared memory size to use when starting the container. + * This value is in bytes. */ - protected DockerClient dockerClient = DockerClientFactory.instance().client(); + @Nullable + private Long shmSize; - /* - * Info about the Docker server; lazily fetched. + // Maintain order in which entries are added, as earlier target location may be a prefix of a later location. + @Deprecated + private Map copyToFileContainerPathMap = new LinkedHashMap<>(); + + // Maintain order in which entries are added, as earlier target location may be a prefix of a later location. + @Setter(AccessLevel.NONE) + @Getter(AccessLevel.MODULE) + @VisibleForTesting + private Map copyToTransferableContainerPathMap = new LinkedHashMap<>(); + + protected final Set dependencies = new HashSet<>(); + + /** + * Unique instance of DockerClient for use by this container object. + * We use {@link DockerClientFactory#lazyClient()} here to avoid eager client creation */ - protected Info dockerDaemonInfo = null; + @Setter(AccessLevel.NONE) + protected DockerClient dockerClient = DockerClientFactory.lazyClient(); - /* + /** * Set during container startup */ - protected String containerId; - protected String containerName; + @Setter(AccessLevel.NONE) + @VisibleForTesting + String containerId; + + @Setter(AccessLevel.NONE) + private InspectContainerResponse containerInfo; + + static WaitStrategy DEFAULT_WAIT_STRATEGY = Wait.defaultWaitStrategy(); /** * The approach to determine if the container is ready. */ @NonNull - protected WaitStrategy waitStrategy = Wait.defaultWaitStrategy(); + protected WaitStrategy waitStrategy = DEFAULT_WAIT_STRATEGY; + + private List> logConsumers = new ArrayList<>(); + + private static final Set AVAILABLE_IMAGE_NAME_CACHE = new HashSet<>(); @Nullable - private InspectContainerResponse containerInfo; + private Map tmpFsMapping; - private List> logConsumers = new ArrayList<>(); + @Setter(AccessLevel.NONE) + private boolean shouldBeReused = false; - private final Set> createContainerCmdModifiers = new LinkedHashSet<>(); + private boolean hostAccessible = false; - private static final Set AVAILABLE_IMAGE_NAME_CACHE = new HashSet<>(); - private static final RateLimiter DOCKER_CLIENT_RATE_LIMITER = RateLimiterBuilder - .newBuilder() - .withRate(1, TimeUnit.SECONDS) - .withConstantThroughput() - .build(); + private final Set createContainerCmdModifiers = loadCreateContainerCmdCustomizers(); + + private ContainerDef containerDef; + + ContainerDef createContainerDef() { + return new ContainerDef(); + } + ContainerDef getContainerDef() { + return this.containerDef; + } + + private Set loadCreateContainerCmdCustomizers() { + ServiceLoader containerCmdCustomizers = ServiceLoader.load( + CreateContainerCmdModifier.class + ); + Set loadedCustomizers = new LinkedHashSet<>(); + for (CreateContainerCmdModifier customizer : containerCmdCustomizers) { + loadedCustomizers.add(customizer); + } + return loadedCustomizers; + } + + public GenericContainer(@NonNull final DockerImageName dockerImageName) { + this(new RemoteDockerImage(dockerImageName)); + } + public GenericContainer(@NonNull final RemoteDockerImage image) { + this.image = image; + this.containerDef = createContainerDef(); + this.containerDef.addNetworkAlias("tc-" + Base58.randomString(8)); + this.containerDef.setImage(image); + } + + /** + * @deprecated use {@link #GenericContainer(DockerImageName)} instead + */ + @Deprecated public GenericContainer() { - this("alpine:3.2"); + this(TestcontainersConfiguration.getInstance().getTinyImage()); } public GenericContainer(@NonNull final String dockerImageName) { - this.setDockerImageName(dockerImageName); + this(new RemoteDockerImage(DockerImageName.parse(dockerImageName))); } public GenericContainer(@NonNull final Future image) { - this.image = image; + this(new RemoteDockerImage(image)); + } + + GenericContainer(@NonNull final ContainerDef containerDef) { + this.image = containerDef.getImage(); + this.containerDef = containerDef; + } + + public void setImage(Future image) { + this.image = new RemoteDockerImage(image); + this.containerDef.setImage(new RemoteDockerImage(image)); + } + + @Override + public List getExposedPorts() { + List exposedPorts = new ArrayList<>(); + for (ExposedPort exposedPort : this.containerDef.getExposedPorts()) { + exposedPorts.add(exposedPort.getPort()); + } + return exposedPorts; + } + + @Override + public void setExposedPorts(List exposedPorts) { + this.containerDef.exposedPorts.clear(); + for (Integer exposedPort : exposedPorts) { + this.containerDef.addExposedTcpPort(exposedPort); + } + } + + /** + * @see #dependsOn(Iterable) + */ + public SELF dependsOn(Startable... startables) { + Collections.addAll(dependencies, startables); + return self(); + } + + /** + * @see #dependsOn(Iterable) + */ + public SELF dependsOn(List startables) { + return this.dependsOn((Iterable) startables); + } + + /** + * Delays this container's creation and start until provided {@link Startable}s start first. + * Note that the circular dependencies are not supported. + * + * @param startables a list of {@link Startable} to depend on + * @see Startables#deepStart(Iterable) + */ + public SELF dependsOn(Iterable startables) { + startables.forEach(dependencies::add); + return self(); + } + + public String getContainerId() { + return containerId; } /** * Starts the container using docker, pulling an image if necessary. */ + @Override + @SneakyThrows({ InterruptedException.class, ExecutionException.class }) public void start() { - Profiler profiler = new Profiler("Container startup"); - profiler.setLogger(logger()); + if (containerId != null) { + return; + } + Startables.deepStart(dependencies).get(); + // trigger LazyDockerClient's resolve so that we fail fast here and not in getDockerImageName() + dockerClient.authConfig(); + doStart(); + } + protected void doStart() { try { - profiler.start("Prepare container configuration and host configuration"); + if (this.waitStrategy != DEFAULT_WAIT_STRATEGY) { + this.containerDef.setWaitStrategy(this.waitStrategy); + } + configure(); logger().debug("Starting container: {}", getDockerImageName()); - logger().debug("Trying to start container: {}", image.get()); AtomicInteger attempt = new AtomicInteger(0); - Unreliables.retryUntilSuccess(startupAttempts, () -> { - logger().debug("Trying to start container: {} (attempt {}/{})", image.get(), attempt.incrementAndGet(), startupAttempts); - tryStart(profiler.startNested("Container startup attempt")); - return true; - }); - + Unreliables.retryUntilSuccess( + startupAttempts, + () -> { + logger() + .debug( + "Trying to start container: {} (attempt {}/{})", + getDockerImageName(), + attempt.incrementAndGet(), + startupAttempts + ); + tryStart(); + return true; + } + ); } catch (Exception e) { - throw new ContainerLaunchException("Container startup failed", e); - } finally { - profiler.stop().log(); + throw new ContainerLaunchException("Container startup failed for image " + getDockerImageName(), e); + } + } + + @UnstableAPI + @SneakyThrows + protected boolean canBeReused() { + for (Class type = getClass(); type != GenericContainer.class; type = type.getSuperclass()) { + try { + Method method = type.getDeclaredMethod("containerIsCreated", String.class); + if (method.getDeclaringClass() != GenericContainer.class) { + logger().warn("{} can't be reused because it overrides {}", getClass(), method.getName()); + return false; + } + } catch (NoSuchMethodException | NoClassDefFoundError e) { + // ignore + } } + + return true; } - private void tryStart(Profiler profiler) { + private void tryStart() { try { - String dockerImageName = image.get(); + String dockerImageName = getDockerImageName(); logger().debug("Starting container: {}", dockerImageName); + Instant startedAt = Instant.now(); logger().info("Creating container for image: {}", dockerImageName); - profiler.start("Create container"); CreateContainerCmd createCommand = dockerClient.createContainerCmd(dockerImageName); applyConfiguration(createCommand); - createContainerCmdModifiers.forEach(hook -> hook.accept(createCommand)); - containerId = createCommand.exec().getId(); - ResourceReaper.instance().registerContainerForCleanup(containerId, dockerImageName); + createCommand.getLabels().putAll(DockerClientFactory.DEFAULT_LABELS); + + boolean reused = false; + final boolean reusable; + if (shouldBeReused) { + if (!canBeReused()) { + throw new IllegalStateException("This container does not support reuse"); + } + + if (TestcontainersConfiguration.getInstance().environmentSupportsReuse()) { + createCommand + .getLabels() + .put(COPIED_FILES_HASH_LABEL, Long.toHexString(hashCopiedFiles().getValue())); + + String hash = hash(createCommand); + + containerId = findContainerForReuse(hash).orElse(null); + + if (containerId != null) { + logger().info("Reusing container with ID: {} and hash: {}", containerId, hash); + reused = true; + } else { + logger().debug("Can't find a reusable running container with hash: {}", hash); + + createCommand.getLabels().put(HASH_LABEL, hash); + } + reusable = true; + } else { + logger() + .warn( + "" + + "Reuse was requested but the environment does not support the reuse of containers\n" + + "To enable reuse of containers, you must set 'testcontainers.reuse.enable=true' in a file located at {}", + Paths.get(System.getProperty("user.home"), ".testcontainers.properties") + ); + reusable = false; + } + } else { + reusable = false; + } + + if (!reusable) { + //noinspection deprecation + createCommand = ResourceReaper.instance().register(this, createCommand); + } + + if (!reused) { + containerId = createCommand.exec().getId(); - logger().info("Starting container with ID: {}", containerId); - profiler.start("Start container"); - dockerClient.startContainerCmd(containerId).exec(); + // TODO use single "copy" invocation (and calculate an hash of the resulting tar archive) + copyToFileContainerPathMap.forEach(this::copyFileToContainer); + + copyToTransferableContainerPathMap.forEach(this::copyFileToContainer); + } + + connectToPortForwardingNetwork(createCommand.getNetworkMode()); + + if (!reused) { + containerIsCreated(containerId); + + logger().info("Container {} is starting: {}", dockerImageName, containerId); + dockerClient.startContainerCmd(containerId).exec(); + } else { + logger().info("Reusing existing container ({}) and not creating a new one", containerId); + } // For all registered output consumers, start following as close to container startup as possible this.logConsumers.forEach(this::followOutput); - logger().info("Container {} is starting: {}", dockerImageName, containerId); + // Wait until inspect container returns the mapped ports + containerInfo = + await() + .atMost(5, TimeUnit.SECONDS) + .pollInterval(DynamicPollInterval.ofMillis(50)) + .pollInSameThread() + .until( + () -> dockerClient.inspectContainerCmd(containerId).exec(), + inspectContainerResponse -> { + Set exposedAndMappedPorts = inspectContainerResponse + .getNetworkSettings() + .getPorts() + .getBindings() + .entrySet() + .stream() + .filter(it -> Objects.nonNull(it.getValue())) // filter out exposed but not yet mapped + .map(Entry::getKey) + .collect(Collectors.toSet()); + + return exposedAndMappedPorts.containsAll(this.containerDef.getExposedPorts()); + } + ); + + String emulationWarning = checkForEmulation(); + if (emulationWarning != null) { + logger().warn(emulationWarning); + } // Tell subclasses that we're starting - profiler.start("Inspecting container"); - containerInfo = dockerClient.inspectContainerCmd(containerId).exec(); - containerName = containerInfo.getName(); - profiler.start("Call containerIsStarting on subclasses"); - containerIsStarting(containerInfo); + containerIsStarting(containerInfo, reused); - // Wait until the container is running (may not be fully started) - profiler.start("Wait until container has started properly, or there's evidence it failed to start."); - - if (!this.startupCheckStrategy.waitUntilStartupSuccessful(dockerClient, containerId)) { + // Wait until the container has reached the desired running state + if (!this.startupCheckStrategy.waitUntilStartupSuccessful(this)) { // Bail out, don't wait for the port to start listening. // (Exception thrown here will be caught below and wrapped) throw new IllegalStateException("Container did not start correctly."); } - profiler.start("Wait until container started properly"); - waitUntilContainerStarted(); + // Wait until the process within the container has become ready for use (e.g. listening on network, log message emitted, etc). + try { + waitUntilContainerStarted(); + } catch (Exception e) { + logger().debug("Wait strategy threw an exception", e); + InspectContainerResponse inspectContainerResponse = null; + try { + inspectContainerResponse = dockerClient.inspectContainerCmd(containerId).exec(); + } catch (NotFoundException notFoundException) { + logger().debug("Container {} not found", containerId, notFoundException); + } - logger().info("Container {} started", dockerImageName); - containerIsStarted(containerInfo); + if (inspectContainerResponse == null) { + throw new IllegalStateException("Wait strategy failed. Container is removed", e); + } + + InspectContainerResponse.ContainerState state = inspectContainerResponse.getState(); + if (Boolean.TRUE.equals(state.getDead())) { + throw new IllegalStateException("Wait strategy failed. Container is dead", e); + } + + if (Boolean.TRUE.equals(state.getOOMKilled())) { + throw new IllegalStateException( + "Wait strategy failed. Container crashed with out-of-memory (OOMKilled)", + e + ); + } + + String error = state.getError(); + if (!StringUtils.isBlank(error)) { + throw new IllegalStateException("Wait strategy failed. Container crashed: " + error, e); + } + + if (!Boolean.TRUE.equals(state.getRunning())) { + throw new IllegalStateException( + "Wait strategy failed. Container exited with code " + state.getExitCode(), + e + ); + } + + throw e; + } + + logger().info("Container {} started in {}", dockerImageName, Duration.between(startedAt, Instant.now())); + containerIsStarted(containerInfo, reused); } catch (Exception e) { + if (e instanceof UndeclaredThrowableException && e.getCause() instanceof Exception) { + e = (Exception) e.getCause(); + } + if (e instanceof InvocationTargetException && e.getCause() instanceof Exception) { + e = (Exception) e.getCause(); + } logger().error("Could not start container", e); - // Log output if startup failed, either due to a container failure or exception (including timeout) - logger().error("Container log output (if any) will follow:"); - FrameConsumerResultCallback resultCallback = new FrameConsumerResultCallback(); - resultCallback.addConsumer(STDOUT, new Slf4jLogConsumer(logger())); - resultCallback.addConsumer(STDERR, new Slf4jLogConsumer(logger())); - dockerClient.logContainerCmd(containerId).withStdOut(true).withStdErr(true).exec(resultCallback); + if (containerId != null) { + // Log output if startup failed, either due to a container failure or exception (including timeout) + final String containerLogs = getLogs(); - // Try to ensure that container log output is shown before proceeding - try { - resultCallback.getCompletionLatch().await(1, TimeUnit.MINUTES); - } catch (InterruptedException ignored) { - // Cannot do anything at this point + if (containerLogs.length() > 0) { + logger().error("Log output from the failed container:\n{}", containerLogs); + } else { + logger().error("There are no stdout/stderr logs available for the failed container"); + } + stop(); } throw new ContainerLaunchException("Could not create/start container", e); - } finally { - profiler.stop(); } } + @VisibleForTesting + Checksum hashCopiedFiles() { + Checksum checksum = new Adler32(); + Stream + .of(copyToFileContainerPathMap, copyToTransferableContainerPathMap) + .flatMap(it -> it.entrySet().stream()) + .sorted(Entry.comparingByValue()) + .forEach(entry -> { + byte[] pathBytes = entry.getValue().getBytes(); + // Add path to the hash + checksum.update(pathBytes, 0, pathBytes.length); + + entry.getKey().updateChecksum(checksum); + }); + return checksum; + } + + @UnstableAPI + @SneakyThrows(JsonProcessingException.class) + final String hash(CreateContainerCmd createCommand) { + DefaultDockerClientConfig dockerClientConfig = DefaultDockerClientConfig.createDefaultConfigBuilder().build(); + + byte[] commandJson = dockerClientConfig + .getObjectMapper() + .copy() + .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) + .writeValueAsBytes(createCommand); + + // TODO add Testcontainers' version to the hash + return Hashing.sha1().hashBytes(commandJson).toString(); + } + + @VisibleForTesting + Optional findContainerForReuse(String hash) { + // TODO locking + return dockerClient + .listContainersCmd() + .withLabelFilter(ImmutableMap.of(HASH_LABEL, hash)) + .withLimit(1) + .withStatusFilter(Arrays.asList("running")) + .exec() + .stream() + .findAny() + .map(it -> it.getId()); + } + /** - * Stops the container. + * Set any custom settings for the create command such as shared memory size. */ - public void stop() { + private HostConfig buildHostConfig(HostConfig config) { + if (shmSize != null) { + config.withShmSize(shmSize); + } + if (tmpFsMapping != null) { + config.withTmpFs(tmpFsMapping); + } + return config; + } + private void connectToPortForwardingNetwork(String networkMode) { + PortForwardingContainer.INSTANCE + .getNetwork() + .map(ContainerNetwork::getNetworkID) + .ifPresent(networkId -> { + if (!Arrays.asList(networkId, "none", "host").contains(networkMode)) { + com.github.dockerjava.api.model.Network network = + this.dockerClient.inspectNetworkCmd().withNetworkId(networkId).exec(); + if (!network.getContainers().containsKey(this.containerId)) { + this.dockerClient.connectToNetworkCmd() + .withContainerId(this.containerId) + .withNetworkId(networkId) + .exec(); + } + } + }); + } + + /** + * Kill and remove the container. + */ + @Override + public void stop() { if (containerId == null) { return; } - String imageName; - try { - imageName = image.get(); - } catch (Exception e) { - imageName = ""; - } + String imageName; + + try { + imageName = getDockerImageName(); + } catch (Exception e) { + imageName = ""; + } - ResourceReaper.instance().stopAndRemoveContainer(containerId, imageName); + containerIsStopping(containerInfo); + ResourceReaper.instance().stopAndRemoveContainer(containerId, imageName); + containerIsStopped(containerInfo); + } finally { + containerId = null; + containerInfo = null; + } } /** @@ -284,106 +672,133 @@ protected Logger logger() { * @param temporary is the volume directory temporary? If true, the directory will be deleted on JVM shutdown. * @return path to the volume directory */ + @Deprecated protected Path createVolumeDirectory(boolean temporary) { - Path directory = new File(".tmp-volume-" + System.currentTimeMillis()).toPath(); + Path directory = new File(".tmp-volume-" + UUID.randomUUID()).toPath(); PathUtils.mkdirp(directory); - if (temporary) Runtime.getRuntime().addShutdownHook(new Thread(() -> { - PathUtils.recursiveDeleteDir(directory); - })); + if (temporary) { + Runtime + .getRuntime() + .addShutdownHook( + new Thread( + DockerClientFactory.TESTCONTAINERS_THREAD_GROUP, + () -> { + PathUtils.recursiveDeleteDir(directory); + } + ) + ); + } return directory; } - protected void configure() { + protected void configure() {} - } + @SuppressWarnings({ "EmptyMethod", "UnusedParameters" }) + protected void containerIsCreated(String containerId) {} - @SuppressWarnings({"EmptyMethod", "UnusedParameters"}) - protected void containerIsStarting(InspectContainerResponse containerInfo) { + @SuppressWarnings({ "EmptyMethod", "UnusedParameters" }) + protected void containerIsStarting(InspectContainerResponse containerInfo) {} + + @SuppressWarnings({ "EmptyMethod", "UnusedParameters" }) + @UnstableAPI + protected void containerIsStarting(InspectContainerResponse containerInfo, boolean reused) { + containerIsStarting(containerInfo); } - @SuppressWarnings({"EmptyMethod", "UnusedParameters"}) - protected void containerIsStarted(InspectContainerResponse containerInfo) { + @SuppressWarnings({ "EmptyMethod", "UnusedParameters" }) + protected void containerIsStarted(InspectContainerResponse containerInfo) {} + + @SuppressWarnings({ "EmptyMethod", "UnusedParameters" }) + @UnstableAPI + protected void containerIsStarted(InspectContainerResponse containerInfo, boolean reused) { + containerIsStarted(containerInfo); } + /** + * A hook that is executed before the container is stopped with {@link #stop()}. + * Warning! This hook won't be executed if the container is terminated during + * the JVM's shutdown hook or by Ryuk. + */ + @SuppressWarnings({ "EmptyMethod", "UnusedParameters" }) + protected void containerIsStopping(InspectContainerResponse containerInfo) {} + + /** + * A hook that is executed after the container is stopped with {@link #stop()}. + * Warning! This hook won't be executed if the container is terminated during + * the JVM's shutdown hook or by Ryuk. + */ + @SuppressWarnings({ "EmptyMethod", "UnusedParameters" }) + protected void containerIsStopped(InspectContainerResponse containerInfo) {} + /** * @return the port on which to check if the container is ready + * @deprecated see {@link GenericContainer#getLivenessCheckPorts()} for replacement */ + @Deprecated protected Integer getLivenessCheckPort() { - if (exposedPorts.size() > 0) { - return getMappedPort(exposedPorts.get(0)); - } else if (portBindings.size() > 0) { - return Integer.valueOf(PortBinding.parse(portBindings.get(0)).getBinding().getHostPortSpec()); + // legacy implementation for backwards compatibility + Iterator exposedPortsIterator = this.containerDef.getExposedPorts().iterator(); + if (exposedPortsIterator.hasNext()) { + return getMappedPort(exposedPortsIterator.next().getPort()); + } else if (!this.containerDef.getPortBindings().isEmpty()) { + return Integer.valueOf( + this.containerDef.getPortBindings().iterator().next().getBinding().getHostPortSpec() + ); } else { return null; } } - private void applyConfiguration(CreateContainerCmd createCommand) { - - // Set up exposed ports (where there are no host port bindings defined) - ExposedPort[] portArray = exposedPorts.stream() - .map(ExposedPort::new) - .toArray(ExposedPort[]::new); - - createCommand.withExposedPorts(portArray); - - // Set up exposed ports that need host port bindings - PortBinding[] portBindingArray = portBindings.stream() - .map(PortBinding::parse) - .toArray(PortBinding[]::new); - - createCommand.withPortBindings(portBindingArray); + /** + * @return the ports on which to check if the container is ready + * @deprecated use {@link #getLivenessCheckPortNumbers()} instead + */ + @NotNull + @NonNull + @Deprecated + protected Set getLivenessCheckPorts() { + final Set result = WaitStrategyTarget.super.getLivenessCheckPortNumbers(); - if (commandParts != null) { - createCommand.withCmd(commandParts); + // for backwards compatibility + if (this.getLivenessCheckPort() != null) { + result.add(this.getLivenessCheckPort()); } - String[] envArray = env.stream() - .toArray(String[]::new); - createCommand.withEnv(envArray); + return result; + } + + @Override + public Set getLivenessCheckPortNumbers() { + return this.getLivenessCheckPorts(); + } - Bind[] bindsArray = binds.stream() - .toArray(Bind[]::new); - createCommand.withBinds(bindsArray); + private void applyConfiguration(CreateContainerCmd createCommand) { + this.containerDef.applyTo(createCommand); + buildHostConfig(createCommand.getHostConfig()); - VolumesFrom[] volumesFromsArray = volumesFroms.stream() - .toArray(VolumesFrom[]::new); + VolumesFrom[] volumesFromsArray = volumesFroms.stream().toArray(VolumesFrom[]::new); createCommand.withVolumesFrom(volumesFromsArray); Set allLinks = new HashSet<>(); Set allLinkedContainerNetworks = new HashSet<>(); - for (Map.Entry linkEntries : linkedContainers.entrySet()) { - + for (Entry linkEntries : linkedContainers.entrySet()) { String alias = linkEntries.getKey(); LinkableContainer linkableContainer = linkEntries.getValue(); - Set links = dockerClient.listContainersCmd().exec().stream() - .filter(container -> container.getNames()[0].endsWith(linkableContainer.getContainerName())) - .map(container -> new Link(container.getNames()[0], alias)) - .collect(Collectors.toSet()); + Set links = findLinksFromThisContainer(alias, linkableContainer); allLinks.addAll(links); - boolean linkableContainerIsRunning = dockerClient.listContainersCmd().exec().stream() - .filter(container -> container.getNames()[0].endsWith(linkableContainer.getContainerName())) - .map(com.github.dockerjava.api.model.Container::getId) - .map(id -> dockerClient.inspectContainerCmd(id).exec()) - .anyMatch(linkableContainerInspectResponse -> linkableContainerInspectResponse.getState().getRunning()); - - if (!linkableContainerIsRunning) { - throw new ContainerLaunchException("Aborting attempt to link to container " + - linkableContainer.getContainerName() + - " as it is not running"); + if (allLinks.size() == 0) { + throw new ContainerLaunchException( + "Aborting attempt to link to container " + + linkableContainer.getContainerName() + + " as it is not running" + ); } - Set linkedContainerNetworks = dockerClient.listContainersCmd().exec().stream() - .filter(container -> container.getNames()[0].endsWith(linkableContainer.getContainerName())) - .filter(container -> container.getNetworkSettings() != null && - container.getNetworkSettings().getNetworks() != null) - .flatMap(container -> container.getNetworkSettings().getNetworks().keySet().stream()) - .distinct() - .collect(Collectors.toSet()); + Set linkedContainerNetworks = findAllNetworksForLinkedContainers(linkableContainer); allLinkedContainerNetworks.addAll(linkedContainerNetworks); } @@ -391,9 +806,12 @@ private void applyConfiguration(CreateContainerCmd createCommand) { allLinkedContainerNetworks.remove("bridge"); if (allLinkedContainerNetworks.size() > 1) { - logger().warn("Container needs to be on more than one custom network to link to other " + - "containers - this is not currently supported. Required networks are: {}", - allLinkedContainerNetworks); + logger() + .warn( + "Container needs to be on more than one custom network to link to other " + + "containers - this is not currently supported. Required networks are: {}", + allLinkedContainerNetworks + ); } Optional networkForLinks = allLinkedContainerNetworks.stream().findFirst(); @@ -402,25 +820,51 @@ private void applyConfiguration(CreateContainerCmd createCommand) { createCommand.withNetworkMode(networkForLinks.get()); } - createCommand.withPublishAllPorts(true); + if (hostAccessible) { + PortForwardingContainer.INSTANCE.start(); + } + PortForwardingContainer.INSTANCE + .getNetwork() + .ifPresent(it -> { + withExtraHost(INTERNAL_HOST_HOSTNAME, it.getIpAddress()); + }); - String[] extraHostsArray = extraHosts.stream() - .toArray(String[]::new); + String[] extraHostsArray = extraHosts.stream().distinct().toArray(String[]::new); createCommand.withExtraHosts(extraHostsArray); - if (networkMode != null) { - createCommand.withNetworkMode(networkMode); - } - if (workingDirectory != null) { createCommand.withWorkingDir(workingDirectory); } - if (privilegedMode) { - createCommand.withPrivileged(privilegedMode); + for (CreateContainerCmdModifier createContainerCmdModifier : this.createContainerCmdModifiers) { + createCommand = createContainerCmdModifier.modify(createCommand); } + } + + private Set findLinksFromThisContainer(String alias, LinkableContainer linkableContainer) { + return dockerClient + .listContainersCmd() + .withStatusFilter(Arrays.asList("running")) + .exec() + .stream() + .flatMap(container -> Stream.of(container.getNames())) + .filter(name -> name.endsWith(linkableContainer.getContainerName())) + .map(name -> new Link(name, alias)) + .collect(Collectors.toSet()); + } - createCommand.withLabels(Collections.singletonMap("org.testcontainers", "true")); + private Set findAllNetworksForLinkedContainers(LinkableContainer linkableContainer) { + return dockerClient + .listContainersCmd() + .exec() + .stream() + .filter(container -> container.getNames()[0].endsWith(linkableContainer.getContainerName())) + .filter(container -> { + return container.getNetworkSettings() != null && container.getNetworkSettings().getNetworks() != null; + }) + .flatMap(container -> container.getNetworkSettings().getNetworks().keySet().stream()) + .distinct() + .collect(Collectors.toSet()); } /** @@ -439,7 +883,12 @@ public SELF waitingFor(@NonNull WaitStrategy waitStrategy) { * @return the {@link WaitStrategy} to use */ protected WaitStrategy getWaitStrategy() { - return waitStrategy; + return this.waitStrategy == DEFAULT_WAIT_STRATEGY ? this.containerDef.getWaitStrategy() : this.waitStrategy; + } + + @Override + public void setWaitStrategy(WaitStrategy waitStrategy) { + this.waitStrategy = waitStrategy; } /** @@ -450,7 +899,36 @@ protected WaitStrategy getWaitStrategy() { * @see #waitingFor(WaitStrategy) */ protected void waitUntilContainerStarted() { - getWaitStrategy().waitUntilReady(this); + WaitStrategy waitStrategy = getWaitStrategy(); + if (waitStrategy != null) { + waitStrategy.waitUntilReady(this); + } + } + + private String checkForEmulation() { + try { + DockerClient dockerClient = DockerClientFactory.instance().client(); + String imageId = getContainerInfo().getImageId(); + String imageArch = dockerClient.inspectImageCmd(imageId).exec().getArch(); + String serverArch = dockerClient.versionCmd().exec().getArch(); + + if (!serverArch.equals(imageArch)) { + return ( + "The architecture '" + + imageArch + + "' for image '" + + getDockerImageName() + + "' (ID " + + imageId + + ") does not match the Docker server architecture '" + + serverArch + + "'. This will cause the container to execute much more slowly due to emulation and may lead to timeout failures." + ); + } + } catch (Exception archCheckException) { + // ignore any exceptions since this is just used for a log message + } + return null; } /** @@ -458,7 +936,7 @@ protected void waitUntilContainerStarted() { */ @Override public void setCommand(@NonNull String command) { - this.commandParts = command.split(" "); + this.containerDef.setCommand(command.split(" ")); } /** @@ -466,25 +944,67 @@ public void setCommand(@NonNull String command) { */ @Override public void setCommand(@NonNull String... commandParts) { - this.commandParts = commandParts; + this.containerDef.setCommand(commandParts); + } + + @Override + public Map getEnvMap() { + return this.containerDef.envVars; } /** * {@inheritDoc} */ @Override - public void addEnv(String key, String value) { - env.add(key + "=" + value); + public List getEnv() { + return this.containerDef.getEnvVars() + .entrySet() + .stream() + .map(it -> it.getKey() + "=" + it.getValue()) + .collect(Collectors.toList()); + } + + @Override + public void setEnv(List env) { + this.containerDef.setEnvVars( + env.stream().map(it -> it.split("=")).collect(Collectors.toMap(it -> it[0], it -> it[1])) + ); } /** * {@inheritDoc} */ @Override - public void addFileSystemBind(String hostPath, String containerPath, BindMode mode) { + public void addEnv(String key, String value) { + this.containerDef.addEnvVar(key, value); + } - final MountableFile mountableFile = MountableFile.forHostPath(hostPath); - binds.add(new Bind(mountableFile.getResolvedPath(), new Volume(containerPath), mode.accessMode)); + /** + * {@inheritDoc} + */ + @Override + public void addFileSystemBind( + final String hostPath, + final String containerPath, + final BindMode mode, + final SelinuxContext selinuxContext + ) { + if (SystemUtils.IS_OS_WINDOWS && hostPath.startsWith("/")) { + // e.g. Docker socket mount + this.containerDef.addBinds( + new Bind(hostPath, new Volume(containerPath), mode.accessMode, selinuxContext.selContext) + ); + } else { + final MountableFile mountableFile = MountableFile.forHostPath(hostPath); + this.containerDef.addBinds( + new Bind( + mountableFile.getResolvedPath(), + new Volume(containerPath), + mode.accessMode, + selinuxContext.selContext + ) + ); + } } /** @@ -509,6 +1029,10 @@ private void addVolumesFrom(Container container, BindMode mode) { volumesFroms.add(new VolumesFrom(container.getContainerName(), mode.accessMode)); } + /** + * @deprecated Links are deprecated (see #465). Please use {@link Network} features instead. + */ + @Deprecated @Override public void addLink(LinkableContainer otherContainer, String alias) { this.linkedContainers.put(alias, otherContainer); @@ -516,24 +1040,12 @@ public void addLink(LinkableContainer otherContainer, String alias) { @Override public void addExposedPort(Integer port) { - exposedPorts.add(port); + this.containerDef.addExposedTcpPort(port); } @Override public void addExposedPorts(int... ports) { - for (int port : ports) { - exposedPorts.add(port); - } - } - - @Override - protected void starting(Description description) { - this.start(); - } - - @Override - protected void finished(Description description) { - this.stop(); + this.containerDef.addExposedTcpPorts(ports); } /** @@ -541,9 +1053,22 @@ protected void finished(Description description) { */ @Override public SELF withExposedPorts(Integer... ports) { - this.setExposedPorts(asList(ports)); + this.setExposedPorts(Lists.newArrayList(ports)); return self(); + } + /** + * Add a TCP container port that should be bound to a fixed port on the docker host. + *

+ * Note that this method is protected scope to discourage use, as clashes or instability are more likely when + * using fixed port mappings. If you need to use this method from a test, please use {@link FixedHostPortGenericContainer} + * instead of GenericContainer. + * + * @param hostPort + * @param containerPort + */ + protected void addFixedExposedPort(int hostPort, int containerPort) { + addFixedExposedPort(hostPort, containerPort, InternetProtocol.TCP); } /** @@ -555,9 +1080,15 @@ public SELF withExposedPorts(Integer... ports) { * * @param hostPort * @param containerPort + * @param protocol */ - protected void addFixedExposedPort(int hostPort, int containerPort) { - portBindings.add(String.format("%d:%d", hostPort, containerPort)); + protected void addFixedExposedPort(int hostPort, int containerPort, InternetProtocol protocol) { + ExposedPort exposedPort = new ExposedPort( + containerPort, + com.github.dockerjava.api.model.InternetProtocol.parse(protocol.name()) + ); + PortBinding portBinding = new PortBinding(Ports.Binding.bindPort(hostPort), exposedPort); + this.containerDef.addPortBindings(portBinding); } /** @@ -574,7 +1105,28 @@ public SELF withEnv(String key, String value) { */ @Override public SELF withEnv(Map env) { - env.forEach(this::addEnv); + env.forEach(this.containerDef::addEnvVar); + return self(); + } + + /** + * {@inheritDoc} + */ + @Override + public SELF withLabel(String key, String value) { + if (key.startsWith("org.testcontainers")) { + throw new IllegalArgumentException("The org.testcontainers namespace is reserved for internal use"); + } + this.containerDef.addLabel(key, value); + return self(); + } + + /** + * {@inheritDoc} + */ + @Override + public SELF withLabels(Map labels) { + labels.forEach(this::withLabel); return self(); } @@ -607,7 +1159,25 @@ public SELF withExtraHost(String hostname, String ipAddress) { @Override public SELF withNetworkMode(String networkMode) { - this.networkMode = networkMode; + this.containerDef.setNetworkMode(networkMode); + return self(); + } + + @Override + public SELF withNetwork(Network network) { + this.containerDef.setNetwork(network); + return self(); + } + + @Override + public SELF withNetworkAliases(String... aliases) { + this.containerDef.addNetworkAliases(aliases); + return self(); + } + + @Override + public SELF withImagePullPolicy(ImagePullPolicy imagePullPolicy) { + this.image = this.image.withImagePullPolicy(imagePullPolicy); return self(); } @@ -615,10 +1185,31 @@ public SELF withNetworkMode(String networkMode) { * {@inheritDoc} */ @Override - public SELF withClasspathResourceMapping(String resourcePath, String containerPath, BindMode mode) { + public SELF withClasspathResourceMapping( + final String resourcePath, + final String containerPath, + final BindMode mode + ) { + return withClasspathResourceMapping(resourcePath, containerPath, mode, SelinuxContext.SHARED); + } + + /** + * {@inheritDoc} + */ + @Override + public SELF withClasspathResourceMapping( + final String resourcePath, + final String containerPath, + final BindMode mode, + final SelinuxContext selinuxContext + ) { final MountableFile mountableFile = MountableFile.forClasspathResource(resourcePath); - this.addFileSystemBind(mountableFile.getResolvedPath(), containerPath, mode); + if (mode == BindMode.READ_WRITE) { + addFileSystemBind(mountableFile.getResolvedPath(), containerPath, mode, selinuxContext); + } else { + withCopyFileToContainer(mountableFile, containerPath); + } return self(); } @@ -634,18 +1225,10 @@ public SELF withStartupTimeout(Duration startupTimeout) { @Override public SELF withPrivilegedMode(boolean mode) { - this.privilegedMode = mode; + this.containerDef.setPrivilegedMode(mode); return self(); } - /** - * {@inheritDoc} - */ - @Override - public String getContainerIpAddress() { - return DockerClientFactory.instance().dockerHostIpAddress(); - } - /** * {@inheritDoc} */ @@ -673,47 +1256,36 @@ public SELF withWorkingDirectory(String workDir) { return self(); } - /** - * Get the IP address that this container may be reached on (may not be the local machine). - * - * @return an IP address - * @deprecated please use getContainerIpAddress() instead - */ - @Deprecated - public String getIpAddress() { - return getContainerIpAddress(); - } - /** * {@inheritDoc} */ @Override - public Boolean isRunning() { - try { - return dockerClient.inspectContainerCmd(containerId).exec().getState().getRunning(); - } catch (DockerException e) { - return false; + public SELF withCopyFileToContainer(MountableFile mountableFile, String containerPath) { + if (copyToFileContainerPathMap.containsKey(mountableFile)) { + throw new IllegalStateException("Path already configured for copy: " + mountableFile); } + copyToFileContainerPathMap.put(mountableFile, containerPath); + return self(); } /** * {@inheritDoc} */ @Override - public Integer getMappedPort(final int originalPort) { - - Preconditions.checkState(containerId != null, "Mapped port can only be obtained after the container is started"); - - Ports.Binding[] binding = new Ports.Binding[0]; - if (containerInfo != null) { - binding = containerInfo.getNetworkSettings().getPorts().getBindings().get(new ExposedPort(originalPort)); - } + public SELF withCopyToContainer(Transferable transferable, String containerPath) { + copyToTransferableContainerPathMap.put(transferable, containerPath); + return self(); + } - if (binding != null && binding.length > 0 && binding[0] != null) { - return Integer.valueOf(binding[0].getHostPortSpec()); - } else { - throw new IllegalArgumentException("Requested port (" + originalPort + ") is not mapped"); - } + /** + * Get the IP address that this container may be reached on (may not be the local machine). + * + * @return an IP address + * @deprecated please use getContainerIpAddress() instead + */ + @Deprecated + public String getIpAddress() { + return getHost(); } /** @@ -722,9 +1294,6 @@ public Integer getMappedPort(final int originalPort) { @Override public void setDockerImageName(@NonNull String dockerImageName) { this.image = new RemoteDockerImage(dockerImageName); - - // Mimic old behavior where we resolve image once it's set - getDockerImageName(); } /** @@ -736,7 +1305,7 @@ public String getDockerImageName() { try { return image.get(); } catch (Exception e) { - throw new ContainerFetchException("Can't get Docker image name from " + image, e); + throw new ContainerFetchException("Can't get Docker image: " + image, e); } } @@ -744,6 +1313,7 @@ public String getDockerImageName() { * {@inheritDoc} */ @Override + @Deprecated public String getTestHostIpAddress() { if (DockerMachineClient.instance().isInstalled()) { try { @@ -752,23 +1322,33 @@ public String getTestHostIpAddress() { throw new IllegalStateException("Could not find a default docker-machine instance"); } - String sshConnectionString = runShellCommand("docker-machine", "ssh", defaultMachine.get(), "echo $SSH_CONNECTION").trim(); + String sshConnectionString = CommandLine + .runShellCommand("docker-machine", "ssh", defaultMachine.get(), "echo $SSH_CONNECTION") + .trim(); if (Strings.isNullOrEmpty(sshConnectionString)) { - throw new IllegalStateException("Could not obtain SSH_CONNECTION environment variable for docker machine " + defaultMachine.get()); + throw new IllegalStateException( + "Could not obtain SSH_CONNECTION environment variable for docker machine " + + defaultMachine.get() + ); } String[] sshConnectionParts = sshConnectionString.split("\\s"); if (sshConnectionParts.length != 4) { - throw new IllegalStateException("Unexpected pattern for SSH_CONNECTION for docker machine - expected 'IP PORT IP PORT' pattern but found '" + sshConnectionString + "'"); + throw new IllegalStateException( + "Unexpected pattern for SSH_CONNECTION for docker machine - expected 'IP PORT IP PORT' pattern but found '" + + sshConnectionString + + "'" + ); } return sshConnectionParts[0]; } catch (Exception e) { throw new RuntimeException(e); } - } else { - throw new UnsupportedOperationException("getTestHostIpAddress() is only implemented for docker-machine right now"); + throw new UnsupportedOperationException( + "getTestHostIpAddress() is only implemented for docker-machine right now" + ); } } @@ -776,175 +1356,172 @@ public String getTestHostIpAddress() { * {@inheritDoc} */ @Override - public void followOutput(Consumer consumer) { - this.followOutput(consumer, OutputFrame.OutputType.STDOUT, OutputFrame.OutputType.STDERR); + public SELF withLogConsumer(Consumer consumer) { + this.logConsumers.add(consumer); + + return self(); } /** * {@inheritDoc} */ @Override - public void followOutput(Consumer consumer, OutputFrame.OutputType... types) { - LogUtils.followOutput(dockerClient, containerId, consumer, types); + @SneakyThrows + public void copyFileFromContainer(String containerPath, String destinationPath) { + Container.super.copyFileFromContainer(containerPath, destinationPath); } /** - * {@inheritDoc} + * Allow container startup to be attempted more than once if an error occurs. To be used if containers are + * 'flaky' but this flakiness is not something that should affect test outcomes. + * + * @param attempts number of attempts */ - @Override - public SELF withLogConsumer(Consumer consumer) { - this.logConsumers.add(consumer); - + public SELF withStartupAttempts(int attempts) { + this.startupAttempts = attempts; return self(); } - @Override - public synchronized Info fetchDockerDaemonInfo() throws IOException { + /** + * Allow low level modifications of {@link CreateContainerCmd} after it was pre-configured in {@link #tryStart()}. + * Invocation happens eagerly on a moment when container is created. + * Warning: this does expose the underlying docker-java API so might change outside of our control. + * + * @param modifier {@link Consumer} of {@link CreateContainerCmd}. + * @return this + */ + public SELF withCreateContainerCmdModifier(Consumer modifier) { + this.createContainerCmdModifiers.add(cmd -> { + modifier.accept(cmd); + return cmd; + }); + return self(); + } - if (this.dockerDaemonInfo == null) { - this.dockerDaemonInfo = this.dockerClient.infoCmd().exec(); - } - return this.dockerDaemonInfo; + /** + * Size of /dev/shm + * + * @param bytes The number of bytes to assign the shared memory. If null, it will apply the Docker default which is 64 MB. + * @return this + */ + public SELF withSharedMemorySize(Long bytes) { + this.shmSize = bytes; + return self(); } /** - * {@inheritDoc} + * First class support for configuring tmpfs + * + * @param mapping path and params of tmpfs/mount flag for container + * @return this */ - @Override - public ExecResult execInContainer(String... command) - throws UnsupportedOperationException, IOException, InterruptedException { + public SELF withTmpFs(Map mapping) { + this.tmpFsMapping = mapping; + return self(); + } - return execInContainer(UTF8, command); + @UnstableAPI + public SELF withReuse(boolean reusable) { + this.shouldBeReused = reusable; + return self(); } /** - * {@inheritDoc} + * Forces access to the tests host machine. + * Use this method if you need to call {@link org.testcontainers.Testcontainers#exposeHostPorts(int...)} + * after you start this container. + * + * @return this */ - @Override - public ExecResult execInContainer(Charset outputCharset, String... command) - throws UnsupportedOperationException, IOException, InterruptedException { - - if (!TestEnvironment.dockerExecutionDriverSupportsExec()) { - // at time of writing, this is the expected result in CircleCI. - throw new UnsupportedOperationException( - "Your docker daemon is running the \"lxc\" driver, which doesn't support \"docker exec\"."); + public SELF withAccessToHost(boolean value) { + this.hostAccessible = value; + return self(); + } - } + @Override + public boolean equals(Object o) { + return this == o; + } - if (!isRunning()) { - throw new IllegalStateException("Container is not running so exec cannot be run"); - } + @Override + public int hashCode() { + return System.identityHashCode(this); + } - this.dockerClient - .execCreateCmd(this.containerId) - .withCmd(command); + @Override + public String getContainerName() { + return getContainerInfo().getName(); + } - logger().debug("Running \"exec\" command: " + String.join(" ", command)); - final ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(this.containerId) - .withAttachStdout(true).withAttachStderr(true).withCmd(command).exec(); + public Network getNetwork() { + return this.containerDef.getNetwork(); + } - final ToStringConsumer stdoutConsumer = new ToStringConsumer(); - final ToStringConsumer stderrConsumer = new ToStringConsumer(); + @Override + public List getBinds() { + return this.containerDef.binds; + } - FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); - callback.addConsumer(OutputFrame.OutputType.STDOUT, stdoutConsumer); - callback.addConsumer(OutputFrame.OutputType.STDERR, stderrConsumer); + @Override + public void setBinds(List binds) { + this.containerDef.setBinds(binds); + } - dockerClient.execStartCmd(execCreateCmdResponse.getId()).exec(callback).awaitCompletion(); + @Override + public String[] getCommandParts() { + return this.containerDef.getCommand(); + } - final ExecResult result = new ExecResult( - stdoutConsumer.toString(outputCharset), - stderrConsumer.toString(outputCharset)); + @Override + public void setCommandParts(String[] commandParts) { + this.containerDef.setCommand(commandParts); + } - logger().trace("stdout: " + result.getStdout()); - logger().trace("stderr: " + result.getStderr()); - return result; + public List getNetworkAliases() { + return new ArrayList<>(this.containerDef.getNetworkAliases()); } - /** - * Allow container startup to be attempted more than once if an error occurs. To be if containers are - * 'flaky' but this flakiness is not something that should affect test outcomes. - * - * @param attempts number of attempts - */ - public void withStartupAttempts(int attempts) { - this.startupAttempts = attempts; + public void setNetworkAliases(List aliases) { + this.containerDef.setNetworkAliases(new HashSet<>(aliases)); } @Override - public void close() { - stop(); + public List getPortBindings() { + return this.containerDef.portBindings.stream() + .map(it -> String.format("%s:%s", it.getBinding(), it.getExposedPort())) + .collect(Collectors.toList()); } - /** - * Allow low level modifications of {@link CreateContainerCmd} after it was pre-configured in {@link #tryStart(Profiler)}. - * Invocation happens eagerly on a moment when container is created. - * Warning: this does expose the underlying docker-java API so might change outside of our control. - * - * @param modifier {@link Consumer} of {@link CreateContainerCmd}. - * @return this - */ - public SELF withCreateContainerCmdModifier(Consumer modifier) { - createContainerCmdModifiers.add(modifier); - return self(); + @Override + public void setPortBindings(List portBindings) { + this.containerDef.setPortBindings(portBindings.stream().map(PortBinding::parse).collect(Collectors.toSet())); } - /** - * Convenience class with access to non-public members of GenericContainer. - */ - public static abstract class AbstractWaitStrategy implements WaitStrategy { - protected GenericContainer container; + public void setPrivilegedMode(boolean mode) { + this.containerDef.setPrivilegedMode(mode); + } - @NonNull - protected Duration startupTimeout = Duration.ofSeconds(60); + public boolean isPrivilegedMode() { + return this.containerDef.isPrivilegedMode(); + } - /** - * Wait until the container has started. - * - * @param container the container for which to wait - */ - @Override - public void waitUntilReady(GenericContainer container) { - this.container = container; - waitUntilReady(); - } + public Map getLabels() { + return this.containerDef.labels; + } - /** - * Wait until {@link #container} has started. - */ - protected abstract void waitUntilReady(); - - /** - * Set the duration of waiting time until container treated as started. - * - * @param startupTimeout timeout - * @return this - * @see WaitStrategy#waitUntilReady(GenericContainer) - */ - public WaitStrategy withStartupTimeout(Duration startupTimeout) { - this.startupTimeout = startupTimeout; - return this; - } + public void setLabels(Map labels) { + this.containerDef.setLabels(labels); + } - /** - * @return the container's logger - */ - protected Logger logger() { - return container.logger(); - } + public String getNetworkMode() { + return this.containerDef.getNetworkMode(); + } - /** - * @return the port on which to check if the container is ready - */ - protected Integer getLivenessCheckPort() { - return container.getLivenessCheckPort(); - } + public void setNetworkMode(String networkMode) { + this.containerDef.setNetworkMode(networkMode); + } - /** - * @return the rate limiter to use - */ - protected RateLimiter getRateLimiter() { - return DOCKER_CLIENT_RATE_LIMITER; - } + public void setNetwork(Network network) { + this.containerDef.setNetwork(network); } } diff --git a/core/src/main/java/org/testcontainers/containers/InternetProtocol.java b/core/src/main/java/org/testcontainers/containers/InternetProtocol.java new file mode 100644 index 00000000000..6574a517cdf --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/InternetProtocol.java @@ -0,0 +1,17 @@ +package org.testcontainers.containers; + +/** + * The IP protocols supported by Docker. + */ +public enum InternetProtocol { + TCP, + UDP; + + public String toDockerNotation() { + return name().toLowerCase(); + } + + public static InternetProtocol fromDockerNotation(String protocol) { + return valueOf(protocol.toUpperCase()); + } +} diff --git a/core/src/main/java/org/testcontainers/containers/LocalDockerCompose.java b/core/src/main/java/org/testcontainers/containers/LocalDockerCompose.java new file mode 100644 index 00000000000..bcd14b3e97e --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/LocalDockerCompose.java @@ -0,0 +1,134 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.core.LocalDirectorySSLConfig; +import com.github.dockerjava.transport.SSLConfig; +import com.google.common.base.Splitter; +import com.google.common.collect.Maps; +import org.slf4j.Logger; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.dockerclient.TransportConfig; +import org.testcontainers.utility.CommandLine; +import org.testcontainers.utility.DockerLoggerFactory; +import org.zeroturnaround.exec.InvalidExitValueException; +import org.zeroturnaround.exec.ProcessExecutor; +import org.zeroturnaround.exec.stream.slf4j.Slf4jStream; + +import java.io.File; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Use local Docker Compose binary, if present. + */ +class LocalDockerCompose implements DockerCompose { + + private final List composeFiles; + + private final String identifier; + + private String cmd = ""; + + private Map env = new HashMap<>(); + + private final String composeExecutable; + + public LocalDockerCompose(String composeExecutable, List composeFiles, String identifier) { + this.composeExecutable = composeExecutable; + this.composeFiles = composeFiles; + this.identifier = identifier; + } + + @Override + public DockerCompose withCommand(String cmd) { + this.cmd = cmd; + return this; + } + + @Override + public DockerCompose withEnv(Map env) { + this.env = env; + return this; + } + + @Override + public void invoke() { + // bail out early + if (!CommandLine.executableExists(this.composeExecutable)) { + throw new ContainerLaunchException( + "Local Docker Compose not found. Is " + this.composeExecutable + " on the PATH?" + ); + } + + final Map environment = Maps.newHashMap(env); + environment.put(ENV_PROJECT_NAME, identifier); + + TransportConfig transportConfig = DockerClientFactory.instance().getTransportConfig(); + SSLConfig sslConfig = transportConfig.getSslConfig(); + if (sslConfig != null) { + if (sslConfig instanceof LocalDirectorySSLConfig) { + environment.put("DOCKER_CERT_PATH", ((LocalDirectorySSLConfig) sslConfig).getDockerCertPath()); + environment.put("DOCKER_TLS_VERIFY", "true"); + } else { + logger() + .warn( + "Couldn't set DOCKER_CERT_PATH. `sslConfig` is present but it's not LocalDirectorySSLConfig." + ); + } + } + String dockerHost = transportConfig.getDockerHost().toString(); + environment.put("DOCKER_HOST", dockerHost); + + final Stream absoluteDockerComposeFilePaths = composeFiles + .stream() + .map(File::getAbsolutePath) + .map(Objects::toString); + + final String composeFileEnvVariableValue = absoluteDockerComposeFilePaths.collect( + Collectors.joining(File.pathSeparator + "") + ); + logger().debug("Set env COMPOSE_FILE={}", composeFileEnvVariableValue); + + final File pwd = composeFiles.get(0).getAbsoluteFile().getParentFile().getAbsoluteFile(); + environment.put(ENV_COMPOSE_FILE, composeFileEnvVariableValue); + + logger().info("Local Docker Compose is running command: {}", cmd); + + final List command = Splitter + .onPattern(" ") + .omitEmptyStrings() + .splitToList(this.composeExecutable + " " + cmd); + + try { + new ProcessExecutor() + .command(command) + .redirectOutput(Slf4jStream.of(logger()).asInfo()) + .redirectError(Slf4jStream.of(logger()).asInfo()) // docker-compose will log pull information to stderr + .environment(environment) + .directory(pwd) + .exitValueNormal() + .executeNoTimeout(); + + logger().info("Docker Compose has finished running"); + } catch (InvalidExitValueException e) { + throw new ContainerLaunchException( + "Local Docker Compose exited abnormally with code " + + e.getExitValue() + + " whilst running command: " + + cmd + ); + } catch (Exception e) { + throw new ContainerLaunchException("Error running local Docker Compose command: " + cmd, e); + } + } + + /** + * @return a logger + */ + private Logger logger() { + return DockerLoggerFactory.getLogger(this.composeExecutable); + } +} diff --git a/core/src/main/java/org/testcontainers/containers/Network.java b/core/src/main/java/org/testcontainers/containers/Network.java new file mode 100644 index 00000000000..27cab80c652 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/Network.java @@ -0,0 +1,108 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.command.CreateNetworkCmd; +import lombok.Builder; +import lombok.Getter; +import lombok.Singular; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.utility.ResourceReaper; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +public interface Network extends AutoCloseable { + Network SHARED = new NetworkImpl(false, null, Collections.emptySet(), null) { + @Override + public void close() { + // Do not allow users to close SHARED network, only ResourceReaper is allowed to close (destroy) it + } + }; + + String getId(); + + @Override + void close(); + + static Network newNetwork() { + return builder().build(); + } + + static NetworkImpl.NetworkImplBuilder builder() { + return NetworkImpl.builder(); + } + + @Builder + @Getter + class NetworkImpl implements Network { + + private final String name = UUID.randomUUID().toString(); + + private Boolean enableIpv6; + + private String driver; + + @Singular + private Set> createNetworkCmdModifiers; + + @Deprecated + private String id; + + private final AtomicBoolean initialized = new AtomicBoolean(); + + @Override + public synchronized String getId() { + if (initialized.compareAndSet(false, true)) { + boolean success = false; + try { + id = create(); + success = true; + } finally { + if (!success) { + initialized.set(false); + } + } + } + return id; + } + + private String create() { + CreateNetworkCmd createNetworkCmd = DockerClientFactory.instance().client().createNetworkCmd(); + + createNetworkCmd.withName(name); + createNetworkCmd.withCheckDuplicate(true); + + if (enableIpv6 != null) { + createNetworkCmd.withEnableIpv6(enableIpv6); + } + + if (driver != null) { + createNetworkCmd.withDriver(driver); + } + + for (Consumer consumer : createNetworkCmdModifiers) { + consumer.accept(createNetworkCmd); + } + + Map labels = createNetworkCmd.getLabels(); + labels = new HashMap<>(labels != null ? labels : Collections.emptyMap()); + labels.putAll(DockerClientFactory.DEFAULT_LABELS); + //noinspection deprecation + labels.putAll(ResourceReaper.instance().getLabels()); + createNetworkCmd.withLabels(labels); + + return createNetworkCmd.exec().getId(); + } + + @Override + public synchronized void close() { + if (initialized.getAndSet(false)) { + ResourceReaper.instance().removeNetworkById(id); + } + } + } +} diff --git a/core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java b/core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java new file mode 100644 index 00000000000..2d3104af6b1 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java @@ -0,0 +1,195 @@ +package org.testcontainers.containers; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.Sets; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FileUtils; +import org.testcontainers.images.ParsedDockerfile; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.nodes.Node; +import org.yaml.snakeyaml.nodes.Tag; +import org.yaml.snakeyaml.representer.Representer; +import org.yaml.snakeyaml.resolver.Resolver; + +import java.io.File; +import java.io.FileInputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * Representation of a docker-compose file, with partial parsing for validation and extraction of a minimal set of + * data. + */ +@Slf4j +@EqualsAndHashCode +class ParsedDockerComposeFile { + + private final Map composeFileContent; + + private final String composeFileName; + + private final File composeFile; + + @Getter + private final Map> serviceNameToImageNames = new HashMap<>(); + + ParsedDockerComposeFile(File composeFile) { + // The default is 50 and a big docker-compose.yml file can easily go above that number. 1,000 should give us some room + LoaderOptions options = new LoaderOptions(); + options.setMaxAliasesForCollections(1_000); + DumperOptions dumperOptions = new DumperOptions(); + + SafeConstructor constructor = new SafeConstructor(options) { + @Override + protected Object constructObject(Node node) { + if (node.getTag().equals(new Tag("!reset")) || node.getTag().equals(new Tag("!override"))) { + return null; + } + return super.constructObject(node); + } + }; + Yaml yaml = new Yaml(constructor, new Representer(dumperOptions), dumperOptions, options, new Resolver()); + try (FileInputStream fileInputStream = FileUtils.openInputStream(composeFile)) { + composeFileContent = yaml.load(fileInputStream); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to parse YAML file from " + composeFile.getAbsolutePath(), e); + } + this.composeFileName = composeFile.getAbsolutePath(); + this.composeFile = composeFile; + parseAndValidate(); + } + + @VisibleForTesting + ParsedDockerComposeFile(Map testContent) { + this.composeFileContent = testContent; + this.composeFileName = ""; + this.composeFile = new File("."); + + parseAndValidate(); + } + + private void parseAndValidate() { + final Map servicesMap; + if (composeFileContent.containsKey("version") && "2.0".equals(composeFileContent.get("version"))) { + log.warn( + "Testcontainers may not be able to clean up networks spawned using Docker Compose v2.0 files. " + + "Please see https://github.com/testcontainers/moby-ryuk/issues/2, and specify 'version: \"2.1\"' or " + + "higher in {}", + composeFileName + ); + } + + if (composeFileContent.containsKey("services")) { + final Object servicesElement = composeFileContent.get("services"); + if (servicesElement == null) { + log.debug( + "Compose file {} has an unknown format: 'version' is set but 'services' is not defined", + composeFileName + ); + return; + } + if (!(servicesElement instanceof Map)) { + log.debug("Compose file {} has an unknown format: 'services' is not Map", composeFileName); + return; + } + + @SuppressWarnings("unchecked") + Map temp = (Map) servicesElement; + servicesMap = temp; + } else { + servicesMap = composeFileContent; + } + + for (Map.Entry entry : servicesMap.entrySet()) { + String serviceName = entry.getKey(); + Object serviceDefinition = entry.getValue(); + if (!(serviceDefinition instanceof Map)) { + log.debug( + "Compose file {} has an unknown format: service '{}' is not Map", + composeFileName, + serviceName + ); + break; + } + + @SuppressWarnings("unchecked") + final Map serviceDefinitionMap = (Map) serviceDefinition; + + validateNoContainerNameSpecified(serviceName, serviceDefinitionMap); + findServiceImageName(serviceName, serviceDefinitionMap); + findImageNamesInDockerfile(serviceName, serviceDefinitionMap); + } + } + + private void validateNoContainerNameSpecified(String serviceName, Map serviceDefinitionMap) { + if (serviceDefinitionMap.containsKey("container_name")) { + throw new IllegalStateException( + String.format( + "Compose file %s has 'container_name' property set for service '%s' but this property is not supported by Testcontainers, consider removing it", + composeFileName, + serviceName + ) + ); + } + } + + private void findServiceImageName(String serviceName, Map serviceDefinitionMap) { + Object result = serviceDefinitionMap.get("image"); + if (result instanceof String) { + final String imageName = (String) result; + log.debug("Resolved dependency image for Docker Compose in {}: {}", composeFileName, imageName); + serviceNameToImageNames.put(serviceName, Sets.newHashSet(imageName)); + } + } + + private void findImageNamesInDockerfile(String serviceName, Map serviceDefinitionMap) { + final Object buildNode = serviceDefinitionMap.get("build"); + Path dockerfilePath = null; + + if (buildNode instanceof Map) { + final Map buildElement = (Map) buildNode; + final Object dockerfileRelativePath = buildElement.get("dockerfile"); + final Object contextRelativePath = buildElement.get("context"); + if (dockerfileRelativePath instanceof String && contextRelativePath instanceof String) { + dockerfilePath = + composeFile + .getAbsoluteFile() + .getParentFile() + .toPath() + .resolve((String) contextRelativePath) + .resolve((String) dockerfileRelativePath) + .normalize(); + } + } else if (buildNode instanceof String) { + dockerfilePath = + composeFile + .getAbsoluteFile() + .getParentFile() + .toPath() + .resolve((String) buildNode) + .resolve("./Dockerfile") + .normalize(); + } + + if (dockerfilePath != null && Files.exists(dockerfilePath)) { + Set resolvedImageNames = new ParsedDockerfile(dockerfilePath).getDependencyImageNames(); + if (!resolvedImageNames.isEmpty()) { + log.debug( + "Resolved Dockerfile dependency images for Docker Compose in {} -> {}: {}", + composeFileName, + dockerfilePath, + resolvedImageNames + ); + this.serviceNameToImageNames.put(serviceName, resolvedImageNames); + } + } + } +} diff --git a/core/src/main/java/org/testcontainers/containers/PortForwardingContainer.java b/core/src/main/java/org/testcontainers/containers/PortForwardingContainer.java new file mode 100644 index 00000000000..dd4b6e94c97 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/PortForwardingContainer.java @@ -0,0 +1,94 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.model.ContainerNetwork; +import com.trilead.ssh2.Connection; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.SneakyThrows; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.AbstractMap; +import java.util.Collections; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; + +public enum PortForwardingContainer { + INSTANCE; + + private static String PASSWORD = UUID.randomUUID().toString(); + + private static ContainerDef DEFINITION = new ContainerDef() { + { + setImage(DockerImageName.parse("testcontainers/sshd:1.4.0")); + addExposedTcpPort(22); + addEnvVar("PASSWORD", PASSWORD); + } + }; + + private GenericContainer container; + + private final Set> exposedPorts = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + @Getter(value = AccessLevel.PRIVATE, lazy = true) + private final Connection sshConnection = createSSHSession(); + + @SneakyThrows + private Connection createSSHSession() { + container = new GenericContainer<>(DEFINITION); + container.start(); + + Connection connection = new Connection(container.getHost(), container.getMappedPort(22)); + + connection.setTCPNoDelay(true); + connection.connect( + (hostname, port, serverHostKeyAlgorithm, serverHostKey) -> true, + (int) Duration.ofSeconds(30).toMillis(), + (int) Duration.ofSeconds(30).toMillis() + ); + + if (!connection.authenticateWithPassword("root", PASSWORD)) { + throw new IllegalStateException("Authentication failed."); + } + + return connection; + } + + @SneakyThrows + public void exposeHostPort(int port) { + exposeHostPort(port, port); + } + + @SneakyThrows + public void exposeHostPort(int hostPort, int containerPort) { + if (exposedPorts.add(new AbstractMap.SimpleEntry<>(hostPort, containerPort))) { + getSshConnection().requestRemotePortForwarding("", containerPort, "localhost", hostPort); + } + } + + void start() { + getSshConnection(); + } + + Optional getNetwork() { + return Optional + .ofNullable(container) + .map(GenericContainer::getContainerInfo) + .flatMap(it -> it.getNetworkSettings().getNetworks().values().stream().findFirst()); + } + + void reset() { + if (container != null) { + container.stop(); + } + container = null; + + ((AtomicReference) (Object) sshConnection).set(null); + + exposedPorts.clear(); + } +} diff --git a/core/src/main/java/org/testcontainers/containers/SelinuxContext.java b/core/src/main/java/org/testcontainers/containers/SelinuxContext.java new file mode 100644 index 00000000000..ec814d5640a --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/SelinuxContext.java @@ -0,0 +1,16 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.model.SELContext; +import lombok.AllArgsConstructor; + +/** + * Possible contexts for use with SELinux + */ +@AllArgsConstructor +public enum SelinuxContext { + SHARED(SELContext.shared), + SINGLE(SELContext.single), + NONE(SELContext.none); + + public final SELContext selContext; +} diff --git a/core/src/main/java/org/testcontainers/containers/SocatContainer.java b/core/src/main/java/org/testcontainers/containers/SocatContainer.java new file mode 100644 index 00000000000..9455ac77227 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/SocatContainer.java @@ -0,0 +1,49 @@ +package org.testcontainers.containers; + +import org.testcontainers.utility.Base58; +import org.testcontainers.utility.DockerImageName; + +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * A socat container is used as a TCP proxy, enabling any TCP port of another container to be exposed + * publicly, even if that container does not make the port public itself. + */ +public class SocatContainer extends GenericContainer { + + private final Map targets = new HashMap<>(); + + public SocatContainer() { + this(DockerImageName.parse("alpine/socat:1.7.4.3-r0")); + } + + public SocatContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + withCreateContainerCmdModifier(it -> it.withEntrypoint("/bin/sh")); + withCreateContainerCmdModifier(it -> it.withName("testcontainers-socat-" + Base58.randomString(8))); + } + + public SocatContainer withTarget(int exposedPort, String host) { + return withTarget(exposedPort, host, exposedPort); + } + + public SocatContainer withTarget(int exposedPort, String host, int internalPort) { + addExposedPort(exposedPort); + targets.put(exposedPort, String.format("%s:%s", host, internalPort)); + return self(); + } + + @Override + protected void configure() { + withCommand( + "-c", + targets + .entrySet() + .stream() + .map(entry -> "socat TCP-LISTEN:" + entry.getKey() + ",fork,reuseaddr TCP:" + entry.getValue()) + .collect(Collectors.joining(" & ")) + ); + } +} diff --git a/core/src/main/java/org/testcontainers/containers/VncRecordingContainer.java b/core/src/main/java/org/testcontainers/containers/VncRecordingContainer.java new file mode 100644 index 00000000000..9e5f6957cdc --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/VncRecordingContainer.java @@ -0,0 +1,171 @@ +package org.testcontainers.containers; + +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.ToString; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Base64; + +/** + * 'Sidekick container' with the sole purpose of recording the VNC screen output from another container. + * + */ +@Getter +@ToString +public class VncRecordingContainer extends GenericContainer { + + private static final String ORIGINAL_RECORDING_FILE_NAME = "/screen.flv"; + + public static final String DEFAULT_VNC_PASSWORD = "secret"; + + public static final int DEFAULT_VNC_PORT = 5900; + + static final VncRecordingFormat DEFAULT_RECORDING_FORMAT = VncRecordingFormat.FLV; + + private final String targetNetworkAlias; + + private String vncPassword = DEFAULT_VNC_PASSWORD; + + private VncRecordingFormat videoFormat = DEFAULT_RECORDING_FORMAT; + + private int vncPort = 5900; + + private int frameRate = 30; + + public VncRecordingContainer(@NonNull GenericContainer targetContainer) { + this( + targetContainer.getNetwork(), + targetContainer + .getNetworkAliases() + .stream() + .findFirst() + .orElseThrow(() -> new IllegalStateException("Target container must have a network alias")) + ); + } + + /** + * Create a sidekick container and attach it to another container. The VNC output of that container will be recorded. + */ + public VncRecordingContainer(@NonNull Network network, @NonNull String targetNetworkAlias) + throws IllegalStateException { + super(DockerImageName.parse("testcontainers/vnc-recorder:1.3.0")); + this.targetNetworkAlias = targetNetworkAlias; + withNetwork(network); + waitingFor( + new LogMessageWaitStrategy() + .withRegEx(".*Connected.*") + .withStartupTimeout(Duration.of(15, ChronoUnit.SECONDS)) + ); + } + + public VncRecordingContainer withVncPassword(@NonNull String vncPassword) { + this.vncPassword = vncPassword; + return this; + } + + public VncRecordingContainer withVncPort(int vncPort) { + this.vncPort = vncPort; + return this; + } + + public VncRecordingContainer withVideoFormat(VncRecordingFormat videoFormat) { + if (videoFormat != null) { + this.videoFormat = videoFormat; + } + return this; + } + + public VncRecordingContainer withFrameRate(int frameRate) { + this.frameRate = frameRate; + return this; + } + + @Override + protected void configure() { + withCreateContainerCmdModifier(it -> it.withEntrypoint("/bin/sh")); + String encodedPassword = Base64.getEncoder().encodeToString(vncPassword.getBytes()); + setCommand( + "-c", + "echo '" + + encodedPassword + + "' | base64 -d > /vnc_password && " + + "flvrec.py -o " + + ORIGINAL_RECORDING_FILE_NAME + + " -d -r " + + frameRate + + " -P /vnc_password " + + targetNetworkAlias + + " " + + vncPort + ); + } + + @SneakyThrows + public InputStream streamRecording() { + String newRecordingFileName = videoFormat.reencodeRecording(this, ORIGINAL_RECORDING_FILE_NAME); + + TarArchiveInputStream archiveInputStream = new TarArchiveInputStream( + dockerClient.copyArchiveFromContainerCmd(getContainerId(), newRecordingFileName).exec() + ); + archiveInputStream.getNextEntry(); + return archiveInputStream; + } + + @SneakyThrows + public void saveRecordingToFile(@NonNull File file) { + try (InputStream inputStream = streamRecording()) { + Files.copy(inputStream, file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } + + @RequiredArgsConstructor + public enum VncRecordingFormat { + FLV("flv") { + @Override + String reencodeRecording(@NonNull VncRecordingContainer container, @NonNull String source) + throws IOException, InterruptedException { + String newFileOutput = "/newScreen.flv"; + container.execInContainer("ffmpeg", "-i", source, "-vcodec", "libx264", newFileOutput); + return newFileOutput; + } + }, + MP4("mp4") { + @Override + String reencodeRecording(@NonNull VncRecordingContainer container, @NonNull String source) + throws IOException, InterruptedException { + String newFileOutput = "/newScreen.mp4"; + container.execInContainer( + "ffmpeg", + "-i", + source, + "-vcodec", + "libx264", + "-movflags", + "faststart", + "-pix_fmt", + "yuv420p", + newFileOutput + ); + return newFileOutput; + } + }; + + abstract String reencodeRecording(VncRecordingContainer container, String source) + throws IOException, InterruptedException; + + @Getter + private final String filenameExtension; + } +} diff --git a/core/src/main/java/org/testcontainers/containers/VncRecordingSidekickContainer.java b/core/src/main/java/org/testcontainers/containers/VncRecordingSidekickContainer.java deleted file mode 100644 index 959ae80e46f..00000000000 --- a/core/src/main/java/org/testcontainers/containers/VncRecordingSidekickContainer.java +++ /dev/null @@ -1,86 +0,0 @@ -package org.testcontainers.containers; - -import com.github.dockerjava.api.command.InspectContainerResponse; -import org.testcontainers.containers.traits.LinkableContainer; -import org.testcontainers.containers.traits.VncService; -import org.testcontainers.utility.TestcontainersConfiguration; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; - -/** - * 'Sidekick container' with the sole purpose of recording the VNC screen output from another container. - */ -public class VncRecordingSidekickContainer, T extends VncService & LinkableContainer> extends GenericContainer { - private final T vncServiceContainer; - private final Path tempDir; - - /** - * Create a sidekick container and link it to another container. The VNC output of that container will be recorded. - * - * @param vncServiceContainer the container whose screen should be recorded. This container must implement VncService and LinkableContainer. - */ - public VncRecordingSidekickContainer(T vncServiceContainer) { - super(TestcontainersConfiguration.getInstance().getVncRecordedContainerImage()); - - this.vncServiceContainer = vncServiceContainer; - - try { - // Use a temporary volume directory that the container should record a file into - this.tempDir = createVolumeDirectory(true); - - // flvrec.py needs to read the VNC password from a file - Path passwordFile = tempDir.resolve("password"); - Files.write(passwordFile, vncServiceContainer.getPassword().getBytes()); - } catch (IOException e) { - throw new RuntimeException(e); - } - - } - - @Override - protected void containerIsStarting(InspectContainerResponse containerInfo) { - // do nothing - } - - @Override - protected Integer getLivenessCheckPort() { - // no liveness check needed - return null; - } - - @Override - protected void configure() { - - addFileSystemBind(tempDir.toAbsolutePath().toString(), "/recording", BindMode.READ_WRITE); - addLink(vncServiceContainer, "vnchost"); - setCommand(// the container entrypoint sets the executable name - "-o", - "/recording/screen.flv", - "-P", - "/recording/password", - "vnchost", - String.valueOf(vncServiceContainer.getPort())); - } - - public Path getRecordingPath() { - return this.tempDir.resolve("screen.flv"); - } - - /** - * Stop the container and move the recording file to a suitable destination - * @param destination the location the recording should be moved to, overwriting any existing file - */ - public void stopAndRetainRecording(File destination) { - super.stop(); - - try { - Files.move(getRecordingPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING); - } catch (IOException e) { - throw new RuntimeException("Could not move recording file from " + getRecordingPath() + " to " + destination, e); - } - } -} diff --git a/core/src/main/java/org/testcontainers/containers/output/BaseConsumer.java b/core/src/main/java/org/testcontainers/containers/output/BaseConsumer.java new file mode 100644 index 00000000000..7679ee8ab57 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/output/BaseConsumer.java @@ -0,0 +1,18 @@ +package org.testcontainers.containers.output; + +import lombok.Getter; +import lombok.Setter; + +import java.util.function.Consumer; + +public abstract class BaseConsumer> implements Consumer { + + @Getter + @Setter + private boolean removeColorCodes = true; + + public SELF withRemoveAnsiCodes(boolean removeAnsiCodes) { + this.removeColorCodes = removeAnsiCodes; + return (SELF) this; + } +} diff --git a/core/src/main/java/org/testcontainers/containers/output/FrameConsumerResultCallback.java b/core/src/main/java/org/testcontainers/containers/output/FrameConsumerResultCallback.java index 6860563344b..8b7767e2e51 100644 --- a/core/src/main/java/org/testcontainers/containers/output/FrameConsumerResultCallback.java +++ b/core/src/main/java/org/testcontainers/containers/output/FrameConsumerResultCallback.java @@ -1,32 +1,29 @@ package org.testcontainers.containers.output; - +import com.github.dockerjava.api.async.ResultCallbackTemplate; import com.github.dockerjava.api.model.Frame; -import com.github.dockerjava.core.async.ResultCallbackTemplate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.HashMap; -import java.util.HashSet; import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.function.Consumer; +import java.util.regex.Pattern; /** * This class can be used as a generic callback for docker-java commands that produce Frames. */ public class FrameConsumerResultCallback extends ResultCallbackTemplate { - private final static Logger LOGGER = LoggerFactory.getLogger(FrameConsumerResultCallback.class); - - private Map> consumers; + private static final Logger LOGGER = LoggerFactory.getLogger(FrameConsumerResultCallback.class); - private CountDownLatch completionLatch = new CountDownLatch(1); + private final Map consumers = new HashMap<>(); - public FrameConsumerResultCallback() { - consumers = new HashMap<>(); - } + private final CountDownLatch completionLatch = new CountDownLatch(1); /** * Set this callback to use the specified consumer for the given output type. @@ -35,19 +32,19 @@ public FrameConsumerResultCallback() { * @param consumer the consumer to use for that output type */ public void addConsumer(OutputFrame.OutputType outputType, Consumer consumer) { - consumers.put(outputType, consumer); + consumers.put(outputType, new LineConsumer(outputType, consumer)); } @Override public void onNext(Frame frame) { if (frame != null) { - OutputFrame outputFrame = OutputFrame.forFrame(frame); - if (outputFrame != null) { - Consumer consumer = consumers.get(outputFrame.getType()); + final OutputFrame.OutputType type = OutputFrame.OutputType.forStreamType(frame.getStreamType()); + if (type != null) { + final LineConsumer consumer = consumers.get(type); if (consumer == null) { - LOGGER.error("got frame with type " + frame.getStreamType() + ", for which no handler is configured"); - } else { - consumer.accept(outputFrame); + LOGGER.error("got frame with type {}, for which no handler is configured", frame.getStreamType()); + } else if (frame.getPayload() != null) { + consumer.processFrame(frame.getPayload()); } } } @@ -58,15 +55,17 @@ public void onError(Throwable throwable) { // Sink any errors try { close(); - } catch (IOException ignored) { } + } catch (IOException ignored) {} } @Override public void close() throws IOException { - // send an END frame to every consumer... but only once per consumer. - for (Consumer consumer : new HashSet<>(consumers.values())) { - consumer.accept(OutputFrame.END); + if (completionLatch.getCount() == 0) { + return; } + + consumers.values().forEach(LineConsumer::processBuffer); + consumers.values().forEach(LineConsumer::end); super.close(); completionLatch.countDown(); @@ -78,4 +77,76 @@ public void close() throws IOException { public CountDownLatch getCompletionLatch() { return completionLatch; } + + private static class LineConsumer { + + private static final Pattern ANSI_COLOR_PATTERN = Pattern.compile("\u001B\\[[0-9;]+m"); + + private final OutputFrame.OutputType type; + + private final Consumer consumer; + + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + + private boolean lastCR = false; + + LineConsumer(final OutputFrame.OutputType type, final Consumer consumer) { + this.type = type; + this.consumer = consumer; + } + + void processFrame(final byte[] b) { + int start = 0; + int i = 0; + while (i < b.length) { + switch (b[i]) { + case '\n': + buffer.write(b, start, i + 1 - start); + start = i + 1; + consume(); + lastCR = false; + break; + case '\r': + if (lastCR) { + consume(); + } + buffer.write(b, start, i + 1 - start); + start = i + 1; + lastCR = true; + break; + default: + if (lastCR) { + consume(); + } + lastCR = false; + } + i++; + } + buffer.write(b, start, b.length - start); + } + + void processBuffer() { + if (buffer.size() > 0) { + consume(); + } + } + + void end() { + consumer.accept(OutputFrame.END); + } + + private void consume() { + final String string = new String(buffer.toByteArray(), StandardCharsets.UTF_8); + final byte[] bytes = processAnsiColorCodes(string).getBytes(StandardCharsets.UTF_8); + consumer.accept(new OutputFrame(type, bytes)); + buffer.reset(); + } + + private String processAnsiColorCodes(final String utf8String) { + if (!(consumer instanceof BaseConsumer) || ((BaseConsumer) consumer).isRemoveColorCodes()) { + return ANSI_COLOR_PATTERN.matcher(utf8String).replaceAll(""); + } + return utf8String; + } + } } diff --git a/core/src/main/java/org/testcontainers/containers/output/OutputFrame.java b/core/src/main/java/org/testcontainers/containers/output/OutputFrame.java index 9948bb451d1..ad0c920cbf8 100644 --- a/core/src/main/java/org/testcontainers/containers/output/OutputFrame.java +++ b/core/src/main/java/org/testcontainers/containers/output/OutputFrame.java @@ -2,19 +2,21 @@ import com.github.dockerjava.api.model.Frame; import com.github.dockerjava.api.model.StreamType; -import com.google.common.base.Charsets; + +import java.nio.charset.StandardCharsets; /** - * Holds a frame of container output (usually one line, possibly more) + * Holds exactly one complete line of container output. Lines are split on newline characters (LF, CR LF). */ public class OutputFrame { public static final OutputFrame END = new OutputFrame(OutputType.END, null); private final OutputType type; + private final byte[] bytes; - public OutputFrame(OutputType type, byte[] bytes) { + public OutputFrame(final OutputType type, final byte[] bytes) { this.type = type; this.bytes = bytes; } @@ -28,33 +30,51 @@ public byte[] getBytes() { } public String getUtf8String() { + return (bytes == null) ? "" : new String(bytes, StandardCharsets.UTF_8); + } + public String getUtf8StringWithoutLineEnding() { if (bytes == null) { - return null; + return ""; } + return new String(bytes, 0, bytes.length - determineLineEndingLength(bytes), StandardCharsets.UTF_8); + } - return new String(bytes, Charsets.UTF_8); + private static int determineLineEndingLength(final byte[] bytes) { + if (bytes.length > 0) { + switch (bytes[bytes.length - 1]) { + case '\r': + return 1; + case '\n': + return ((bytes.length > 1) && (bytes[bytes.length - 2] == '\r')) ? 2 : 1; + } + } + return 0; } public enum OutputType { - STDOUT, STDERR, END; + STDOUT, + STDERR, + END; public static OutputType forStreamType(StreamType streamType) { switch (streamType) { - case RAW: return STDOUT; - case STDOUT: return STDOUT; - case STDERR: return STDERR; - default: return null; + case RAW: + case STDOUT: + return STDOUT; + case STDERR: + return STDERR; + default: + return null; } } } public static OutputFrame forFrame(Frame frame) { - OutputType outputType = OutputType.forStreamType(frame.getStreamType()); + final OutputType outputType = OutputType.forStreamType(frame.getStreamType()); if (outputType == null) { return null; } return new OutputFrame(outputType, frame.getPayload()); } - } diff --git a/core/src/main/java/org/testcontainers/containers/output/Slf4jLogConsumer.java b/core/src/main/java/org/testcontainers/containers/output/Slf4jLogConsumer.java index 7b04007ef72..a683781f213 100644 --- a/core/src/main/java/org/testcontainers/containers/output/Slf4jLogConsumer.java +++ b/core/src/main/java/org/testcontainers/containers/output/Slf4jLogConsumer.java @@ -1,21 +1,31 @@ package org.testcontainers.containers.output; import org.slf4j.Logger; +import org.slf4j.MDC; -import java.util.function.Consumer; -import java.util.regex.Pattern; +import java.util.HashMap; +import java.util.Map; /** * A consumer for container output that logs output to an SLF4J logger. */ -public class Slf4jLogConsumer implements Consumer { +public class Slf4jLogConsumer extends BaseConsumer { + private final Logger logger; - private String prefix = ""; - private static final Pattern ANSI_CODE_PATTERN = Pattern.compile("\\[\\d[ABCD]"); + private final Map mdc = new HashMap<>(); + + private boolean separateOutputStreams; + + private String prefix = ""; public Slf4jLogConsumer(Logger logger) { + this(logger, false); + } + + public Slf4jLogConsumer(Logger logger, boolean separateOutputStreams) { this.logger = logger; + this.separateOutputStreams = separateOutputStreams; } public Slf4jLogConsumer withPrefix(String prefix) { @@ -23,29 +33,54 @@ public Slf4jLogConsumer withPrefix(String prefix) { return this; } + public Slf4jLogConsumer withMdc(String key, String value) { + mdc.put(key, value); + return this; + } + + public Slf4jLogConsumer withMdc(Map mdc) { + this.mdc.putAll(mdc); + return this; + } + + public Slf4jLogConsumer withSeparateOutputStreams() { + this.separateOutputStreams = true; + return this; + } + @Override public void accept(OutputFrame outputFrame) { - if (outputFrame != null) { - String utf8String = outputFrame.getUtf8String(); - - if (utf8String != null) { - OutputFrame.OutputType outputType = outputFrame.getType(); - String message = utf8String.trim(); - - if (ANSI_CODE_PATTERN.matcher(message).matches()) { - return; - } - - switch (outputType) { - case END: - break; - case STDOUT: - case STDERR: - logger.info("{}{}: {}", prefix, outputType, message); - break; - default: - throw new IllegalArgumentException("Unexpected outputType " + outputType); - } + final OutputFrame.OutputType outputType = outputFrame.getType(); + final String utf8String = outputFrame.getUtf8StringWithoutLineEnding(); + + final Map originalMdc = MDC.getCopyOfContextMap(); + MDC.setContextMap(mdc); + try { + switch (outputType) { + case END: + break; + case STDOUT: + if (separateOutputStreams) { + logger.info("{}{}", prefix.isEmpty() ? "" : (prefix + ": "), utf8String); + } else { + logger.info("{}{}: {}", prefix, outputType, utf8String); + } + break; + case STDERR: + if (separateOutputStreams) { + logger.error("{}{}", prefix.isEmpty() ? "" : (prefix + ": "), utf8String); + } else { + logger.info("{}{}: {}", prefix, outputType, utf8String); + } + break; + default: + throw new IllegalArgumentException("Unexpected outputType " + outputType); + } + } finally { + if (originalMdc == null) { + MDC.clear(); + } else { + MDC.setContextMap(originalMdc); } } } diff --git a/core/src/main/java/org/testcontainers/containers/output/ToStringConsumer.java b/core/src/main/java/org/testcontainers/containers/output/ToStringConsumer.java index f46e4b9a318..490e8d70cba 100644 --- a/core/src/main/java/org/testcontainers/containers/output/ToStringConsumer.java +++ b/core/src/main/java/org/testcontainers/containers/output/ToStringConsumer.java @@ -5,21 +5,20 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.Charset; -import java.util.function.Consumer; /** * Created by rnorth on 26/03/2016. */ -public class ToStringConsumer implements Consumer { +public class ToStringConsumer extends BaseConsumer { - private ByteArrayOutputStream stringBuffer = new ByteArrayOutputStream(); + private final ByteArrayOutputStream stringBuffer = new ByteArrayOutputStream(); @Override public void accept(OutputFrame outputFrame) { try { - if (outputFrame.getBytes() != null) { - stringBuffer.write(outputFrame.getBytes()); - stringBuffer.flush(); + final byte[] bytes = outputFrame.getBytes(); + if (bytes != null) { + stringBuffer.write(bytes); } } catch (IOException e) { throw new RuntimeException(e); diff --git a/core/src/main/java/org/testcontainers/containers/output/WaitingConsumer.java b/core/src/main/java/org/testcontainers/containers/output/WaitingConsumer.java index 7c9bcec644e..e79c234058e 100644 --- a/core/src/main/java/org/testcontainers/containers/output/WaitingConsumer.java +++ b/core/src/main/java/org/testcontainers/containers/output/WaitingConsumer.java @@ -6,14 +6,13 @@ import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import java.util.function.Consumer; import java.util.function.Predicate; /** * A consumer for container output that buffers lines in a {@link java.util.concurrent.BlockingDeque} and enables tests * to wait for a matching condition. */ -public class WaitingConsumer implements Consumer { +public class WaitingConsumer extends BaseConsumer { private static final Logger LOGGER = LoggerFactory.getLogger(WaitingConsumer.class); @@ -43,8 +42,8 @@ public LinkedBlockingDeque getFrames() { * @param predicate a predicate to test against each frame */ public void waitUntil(Predicate predicate) throws TimeoutException { - // ~2.9 million centuries ought to be enough for anyone - waitUntil(predicate, Long.MAX_VALUE); + // ~2.9 thousands centuries ought to be enough for anyone + waitUntil(predicate, Long.MAX_VALUE, 1); } /** @@ -57,22 +56,47 @@ public void waitUntil(Predicate predicate) throws TimeoutException * @param limit maximum time to wait * @param limitUnit maximum time to wait (units) */ - public void waitUntil(Predicate predicate, long limit, TimeUnit limitUnit) throws TimeoutException { - long expiry = limitUnit.toMillis(limit) + System.currentTimeMillis(); + public void waitUntil(Predicate predicate, int limit, TimeUnit limitUnit) throws TimeoutException { + waitUntil(predicate, limit, limitUnit, 1); + } - waitUntil(predicate, expiry); + /** + * Wait until any frame (usually, line) of output matches the provided predicate. + *

+ * Note that lines will often have a trailing newline character, and this is not stripped off before the + * predicate is tested. + * + * @param predicate a predicate to test against each frame + * @param limit maximum time to wait + * @param limitUnit maximum time to wait (units) + * @param times number of times the predicate has to match + */ + public void waitUntil(Predicate predicate, long limit, TimeUnit limitUnit, int times) + throws TimeoutException { + long timeoutLimitInNanos = limitUnit.toNanos(limit); + + waitUntil(predicate, timeoutLimitInNanos, times); } - private void waitUntil(Predicate predicate, long expiry) throws TimeoutException { - while (System.currentTimeMillis() < expiry) { + private void waitUntil(Predicate predicate, long timeoutLimitInNanos, int times) + throws TimeoutException { + int numberOfMatches = 0; + + final long startTime = System.nanoTime(); + + while (System.nanoTime() - startTime < timeoutLimitInNanos) { try { - OutputFrame frame = frames.pollLast(100, TimeUnit.MILLISECONDS); + final OutputFrame frame = frames.pollLast(100, TimeUnit.MILLISECONDS); if (frame != null) { - LOGGER.debug("{}: {}", frame.getType(), frame.getUtf8String()); + LOGGER.debug("{}: {}", frame.getType(), frame.getUtf8StringWithoutLineEnding()); if (predicate.test(frame)) { - return; + numberOfMatches++; + + if (numberOfMatches == times) { + return; + } } } @@ -92,8 +116,13 @@ private void waitUntil(Predicate predicate, long expiry) throws Tim /** * Wait until Docker closes the stream of output. */ - public void waitUntilEnd() throws TimeoutException { - waitUntilEnd(Long.MAX_VALUE); + public void waitUntilEnd() { + try { + waitUntilEnd(Long.MAX_VALUE); + } catch (TimeoutException e) { + // timeout condition can never occur in a realistic timeframe + throw new IllegalStateException(e); + } } /** @@ -103,13 +132,13 @@ public void waitUntilEnd() throws TimeoutException { * @param limitUnit maximum time to wait (units) */ public void waitUntilEnd(long limit, TimeUnit limitUnit) throws TimeoutException { - long expiry = limitUnit.toMillis(limit) + System.currentTimeMillis(); + long expiry = limitUnit.toNanos(limit) + System.nanoTime(); waitUntilEnd(expiry); } private void waitUntilEnd(Long expiry) throws TimeoutException { - while (System.currentTimeMillis() < expiry) { + while (System.nanoTime() < expiry) { try { OutputFrame frame = frames.pollLast(100, TimeUnit.MILLISECONDS); diff --git a/core/src/main/java/org/testcontainers/containers/startupcheck/IndefiniteWaitOneShotStartupCheckStrategy.java b/core/src/main/java/org/testcontainers/containers/startupcheck/IndefiniteWaitOneShotStartupCheckStrategy.java index f719cb1d476..a547b7885e3 100644 --- a/core/src/main/java/org/testcontainers/containers/startupcheck/IndefiniteWaitOneShotStartupCheckStrategy.java +++ b/core/src/main/java/org/testcontainers/containers/startupcheck/IndefiniteWaitOneShotStartupCheckStrategy.java @@ -12,6 +12,7 @@ * It has to be assumed that the container will stop of its own accord, either with a success or failure exit code. */ public class IndefiniteWaitOneShotStartupCheckStrategy extends OneShotStartupCheckStrategy { + @Override public boolean waitUntilStartupSuccessful(DockerClient dockerClient, String containerId) { while (checkStartupState(dockerClient, containerId) == StartupStatus.NOT_YET_KNOWN) { diff --git a/core/src/main/java/org/testcontainers/containers/startupcheck/IsRunningStartupCheckStrategy.java b/core/src/main/java/org/testcontainers/containers/startupcheck/IsRunningStartupCheckStrategy.java index 6a296e0329c..f161a8e158e 100644 --- a/core/src/main/java/org/testcontainers/containers/startupcheck/IsRunningStartupCheckStrategy.java +++ b/core/src/main/java/org/testcontainers/containers/startupcheck/IsRunningStartupCheckStrategy.java @@ -2,6 +2,7 @@ import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.command.InspectContainerResponse; +import org.testcontainers.containers.GenericContainer; import org.testcontainers.utility.DockerStatus; /** @@ -10,13 +11,31 @@ */ public class IsRunningStartupCheckStrategy extends StartupCheckStrategy { + @SuppressWarnings("deprecation") + @Override + public boolean waitUntilStartupSuccessful(GenericContainer container) { + // Optimization: container already has the initial "after start" state, check it first + if (checkState(container.getContainerInfo().getState()) == StartupStatus.SUCCESSFUL) { + return true; + } + return super.waitUntilStartupSuccessful(container); + } + @Override public StartupStatus checkStartupState(DockerClient dockerClient, String containerId) { InspectContainerResponse.ContainerState state = getCurrentState(dockerClient, containerId); - if (state.getRunning()) { + return checkState(state); + } + + private StartupStatus checkState(InspectContainerResponse.ContainerState state) { + if (Boolean.TRUE.equals(state.getRunning())) { return StartupStatus.SUCCESSFUL; - } else if (!DockerStatus.isContainerExitCodeSuccess(state)) { - return StartupStatus.FAILED; + } else if (DockerStatus.isContainerStopped(state)) { + if (DockerStatus.isContainerExitCodeSuccess(state)) { + return StartupStatus.SUCCESSFUL; + } else { + return StartupStatus.FAILED; + } } else { return StartupStatus.NOT_YET_KNOWN; } diff --git a/core/src/main/java/org/testcontainers/containers/startupcheck/MinimumDurationRunningStartupCheckStrategy.java b/core/src/main/java/org/testcontainers/containers/startupcheck/MinimumDurationRunningStartupCheckStrategy.java index 3ce50446b77..ce29e0104e1 100644 --- a/core/src/main/java/org/testcontainers/containers/startupcheck/MinimumDurationRunningStartupCheckStrategy.java +++ b/core/src/main/java/org/testcontainers/containers/startupcheck/MinimumDurationRunningStartupCheckStrategy.java @@ -35,5 +35,4 @@ public StartupStatus checkStartupState(DockerClient dockerClient, String contain } return StartupStatus.NOT_YET_KNOWN; } - } diff --git a/core/src/main/java/org/testcontainers/containers/startupcheck/StartupCheckStrategy.java b/core/src/main/java/org/testcontainers/containers/startupcheck/StartupCheckStrategy.java index f592411b4fb..50fdcdb8b9b 100644 --- a/core/src/main/java/org/testcontainers/containers/startupcheck/StartupCheckStrategy.java +++ b/core/src/main/java/org/testcontainers/containers/startupcheck/StartupCheckStrategy.java @@ -5,37 +5,61 @@ import org.rnorth.ducttape.ratelimits.RateLimiter; import org.rnorth.ducttape.ratelimits.RateLimiterBuilder; import org.rnorth.ducttape.unreliables.Unreliables; +import org.testcontainers.containers.GenericContainer; +import java.time.Duration; import java.util.concurrent.TimeUnit; -import static org.testcontainers.containers.GenericContainer.CONTAINER_RUNNING_TIMEOUT_SEC; - /** * Approach to determine whether a container has 'started up' correctly. */ public abstract class StartupCheckStrategy { private static final RateLimiter DOCKER_CLIENT_RATE_LIMITER = RateLimiterBuilder - .newBuilder() - .withRate(1, TimeUnit.SECONDS) - .withConstantThroughput() - .build(); + .newBuilder() + .withRate(1, TimeUnit.SECONDS) + .withConstantThroughput() + .build(); + + private Duration timeout = Duration.ofSeconds(GenericContainer.CONTAINER_RUNNING_TIMEOUT_SEC); + + @SuppressWarnings("unchecked") + public SELF withTimeout(Duration timeout) { + this.timeout = timeout; + return (SELF) this; + } + + /** + * + * @deprecated internal API + */ + @Deprecated + public boolean waitUntilStartupSuccessful(GenericContainer container) { + return waitUntilStartupSuccessful(container.getDockerClient(), container.getContainerId()); + } public boolean waitUntilStartupSuccessful(DockerClient dockerClient, String containerId) { - final Boolean[] startedOK = {null}; - Unreliables.retryUntilTrue(CONTAINER_RUNNING_TIMEOUT_SEC, TimeUnit.SECONDS, () -> { - //noinspection CodeBlock2Expr - return DOCKER_CLIENT_RATE_LIMITER.getWhenReady(() -> { - StartupStatus state = checkStartupState(dockerClient, containerId); - switch (state) { - case SUCCESSFUL: startedOK[0] = true; - return true; - case FAILED: startedOK[0] = false; - return true; - default: return false; - } - }); - }); + final Boolean[] startedOK = { null }; + Unreliables.retryUntilTrue( + (int) timeout.toMillis(), + TimeUnit.MILLISECONDS, + () -> { + //noinspection CodeBlock2Expr + return DOCKER_CLIENT_RATE_LIMITER.getWhenReady(() -> { + StartupStatus state = checkStartupState(dockerClient, containerId); + switch (state) { + case SUCCESSFUL: + startedOK[0] = true; + return true; + case FAILED: + startedOK[0] = false; + return true; + default: + return false; + } + }); + } + ); return startedOK[0]; } @@ -46,6 +70,8 @@ protected InspectContainerResponse.ContainerState getCurrentState(DockerClient d } public enum StartupStatus { - NOT_YET_KNOWN, SUCCESSFUL, FAILED + NOT_YET_KNOWN, + SUCCESSFUL, + FAILED, } } diff --git a/core/src/main/java/org/testcontainers/containers/traits/LinkableContainer.java b/core/src/main/java/org/testcontainers/containers/traits/LinkableContainer.java index b3593470674..4973c4dc9c1 100644 --- a/core/src/main/java/org/testcontainers/containers/traits/LinkableContainer.java +++ b/core/src/main/java/org/testcontainers/containers/traits/LinkableContainer.java @@ -2,8 +2,10 @@ /** * A container which can be linked to by other containers. + * + * @deprecated Links are deprecated (see #465). Please use {@link org.testcontainers.containers.Network} features instead. */ +@Deprecated public interface LinkableContainer { - String getContainerName(); } diff --git a/core/src/main/java/org/testcontainers/containers/traits/VncService.java b/core/src/main/java/org/testcontainers/containers/traits/VncService.java deleted file mode 100644 index 6329805b74d..00000000000 --- a/core/src/main/java/org/testcontainers/containers/traits/VncService.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.testcontainers.containers.traits; - -/** - * A container which exposes a VNC server. - */ -public interface VncService { - /** - * @return a URL which can be used to connect to the VNC server from the machine running the tests. Exposed for convenience, e.g. to aid manual debugging - */ - String getVncAddress(); - - /** - * @return the VNC password for this server - */ - String getPassword(); - - /** - * @return the port number on which the VNC server is running - */ - int getPort(); -} diff --git a/core/src/main/java/org/testcontainers/containers/wait/HostPortWaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/HostPortWaitStrategy.java deleted file mode 100644 index 3b3098a5372..00000000000 --- a/core/src/main/java/org/testcontainers/containers/wait/HostPortWaitStrategy.java +++ /dev/null @@ -1,108 +0,0 @@ -package org.testcontainers.containers.wait; - -import lombok.extern.slf4j.Slf4j; -import org.rnorth.ducttape.TimeoutException; -import org.rnorth.ducttape.unreliables.Unreliables; -import org.testcontainers.DockerClientFactory; -import org.testcontainers.containers.ContainerLaunchException; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.dockerclient.ProxiedUnixSocketClientProviderStrategy; -import org.testcontainers.dockerclient.WindowsClientProviderStrategy; - -import java.net.Socket; -import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.TimeUnit; - -/** - * Waits until a socket connection can be established on a port exposed or mapped by the container. - * - * @author richardnorth - */ -@Slf4j -public class HostPortWaitStrategy extends GenericContainer.AbstractWaitStrategy { - - private static final String SUCCESS_MARKER = "TESTCONTAINERS_SUCCESS"; - @Override - protected void waitUntilReady() { - final Integer port = getLivenessCheckPort(); - if (null == port) { - log.debug("Liveness check port of {} is empty. Not waiting.", container.getContainerName()); - return; - } - - Callable checkStrategy; - - if (shouldCheckWithCommand()) { - List exposedPorts = container.getExposedPorts(); - - Integer exposedPort = exposedPorts.stream() - .filter(it -> port.equals(container.getMappedPort(it))) - .findFirst() - .orElse(null); - - if (null == exposedPort) { - log.warn("Liveness check port of {} is set to {}, but it's not listed in exposed ports.", - container.getContainerName(), port); - return; - } - - String[][] commands = { - { "/bin/sh", "-c", "nc -vz -w 1 localhost " + exposedPort + " && echo " + SUCCESS_MARKER }, - { "/bin/bash", "-c", " { - for (String[] command : commands) { - try { - if (container.execInContainer(command).getStdout().contains(SUCCESS_MARKER)) { - return true; - } - } catch (InterruptedException e) { - throw new RuntimeException(e); - } catch (Exception e) { - continue; - } - } - - return false; - }; - } else { - checkStrategy = () -> { - new Socket(container.getContainerIpAddress(), port).close(); - return true; - }; - } - - try { - Unreliables.retryUntilTrue((int) startupTimeout.getSeconds(), TimeUnit.SECONDS, () -> { - return getRateLimiter().getWhenReady(() -> { - try { - return checkStrategy.call(); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - }); - - } catch (TimeoutException e) { - throw new ContainerLaunchException("Timed out waiting for container port to open (" + - container.getContainerIpAddress() + ":" + port + " should be listening)"); - } - } - - private boolean shouldCheckWithCommand() { - // Special case for Docker for Mac, see #160 - if(DockerClientFactory.instance().isUsing(ProxiedUnixSocketClientProviderStrategy.class) - && System.getProperty("os.name").toLowerCase().contains("mac")) { - return true; - } - - // Special case for Docker for Windows, see #160 - if (DockerClientFactory.instance().isUsing(WindowsClientProviderStrategy.class)) { - return true; - } - - return false; - } -} diff --git a/core/src/main/java/org/testcontainers/containers/wait/HttpWaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/HttpWaitStrategy.java deleted file mode 100644 index abe3e110676..00000000000 --- a/core/src/main/java/org/testcontainers/containers/wait/HttpWaitStrategy.java +++ /dev/null @@ -1,158 +0,0 @@ -package org.testcontainers.containers.wait; - -import com.google.common.base.Strings; -import com.google.common.io.BaseEncoding; -import org.rnorth.ducttape.TimeoutException; -import org.testcontainers.DockerClientFactory; -import org.testcontainers.containers.ContainerLaunchException; -import org.testcontainers.containers.GenericContainer; - -import java.io.IOException; -import java.net.HttpURLConnection; -import java.net.URI; -import java.net.URL; -import java.util.concurrent.TimeUnit; - -import static org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess; - -/** - * Waits until an HTTP(S) endpoint returns a given status code. - * - * @author Pete Cornish {@literal } - */ -public class HttpWaitStrategy extends GenericContainer.AbstractWaitStrategy { - /** - * Authorization HTTP header. - */ - private static final String HEADER_AUTHORIZATION = "Authorization"; - - /** - * Basic Authorization scheme prefix. - */ - private static final String AUTH_BASIC = "Basic "; - - private String path = "/"; - private int statusCode = HttpURLConnection.HTTP_OK; - private boolean tlsEnabled; - private String username; - private String password; - - /** - * Waits for the given status code. - * - * @param statusCode the expected status code - * @return this - */ - public HttpWaitStrategy forStatusCode(int statusCode) { - this.statusCode = statusCode; - return this; - } - - /** - * Waits for the given path. - * - * @param path the path to check - * @return this - */ - public HttpWaitStrategy forPath(String path) { - this.path = path; - return this; - } - - /** - * Indicates that the status check should use HTTPS. - * - * @return this - */ - public HttpWaitStrategy usingTls() { - this.tlsEnabled = true; - return this; - } - - /** - * Authenticate with HTTP Basic Authorization credentials. - * - * @param username the username - * @param password the password - * @return this - */ - public HttpWaitStrategy withBasicCredentials(String username, String password) { - this.username = username; - this.password = password; - return this; - } - - @Override - protected void waitUntilReady() { - final Integer livenessCheckPort = getLivenessCheckPort(); - if (null == livenessCheckPort) { - logger().warn("No exposed ports or mapped ports - cannot wait for status"); - return; - } - - final String uri = buildLivenessUri(livenessCheckPort).toString(); - logger().info("Waiting for {} seconds for URL: {}", startupTimeout.getSeconds(), uri); - - // try to connect to the URL - try { - retryUntilSuccess((int) startupTimeout.getSeconds(), TimeUnit.SECONDS, () -> { - getRateLimiter().doWhenReady(() -> { - try { - final HttpURLConnection connection = (HttpURLConnection) new URL(uri).openConnection(); - - // authenticate - if (!Strings.isNullOrEmpty(username)) { - connection.setRequestProperty(HEADER_AUTHORIZATION, buildAuthString(username, password)); - connection.setUseCaches(false); - } - - connection.setRequestMethod("GET"); - connection.connect(); - - if (statusCode != connection.getResponseCode()) { - throw new RuntimeException(String.format("HTTP response code was: %s", - connection.getResponseCode())); - } - - } catch (IOException e) { - throw new RuntimeException(e); - } - }); - return true; - }); - - } catch (TimeoutException e) { - throw new ContainerLaunchException(String.format( - "Timed out waiting for URL to be accessible (%s should return HTTP %s)", uri, statusCode)); - } - } - - /** - * Build the URI on which to check if the container is ready. - * - * @param livenessCheckPort the liveness port - * @return the liveness URI - */ - private URI buildLivenessUri(int livenessCheckPort) { - final String scheme = (tlsEnabled ? "https" : "http") + "://"; - final String host = container.getContainerIpAddress(); - - final String portSuffix; - if ((tlsEnabled && 443 == livenessCheckPort) || (!tlsEnabled && 80 == livenessCheckPort)) { - portSuffix = ""; - } else { - portSuffix = ":" + String.valueOf(livenessCheckPort); - } - - return URI.create(scheme + host + portSuffix + path); - } - - /** - * @param username the username - * @param password the password - * @return a basic authentication string for the given credentials - */ - private String buildAuthString(String username, String password) { - return AUTH_BASIC + BaseEncoding.base64().encode((username + ":" + password).getBytes()); - } -} diff --git a/core/src/main/java/org/testcontainers/containers/wait/Wait.java b/core/src/main/java/org/testcontainers/containers/wait/Wait.java deleted file mode 100644 index 4a6717df36b..00000000000 --- a/core/src/main/java/org/testcontainers/containers/wait/Wait.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.testcontainers.containers.wait; - -import java.net.HttpURLConnection; - -/** - * Convenience class with logic for building common {@link WaitStrategy} instances. - * - * @author Pete Cornish {@literal } - */ -public class Wait { - /** - * Convenience method to return the default WaitStrategy. - * - * @return a WaitStrategy - */ - public static WaitStrategy defaultWaitStrategy() { - return forListeningPort(); - } - - /** - * Convenience method to return a WaitStrategy for an exposed or mapped port. - * - * @return the WaitStrategy - * @see HostPortWaitStrategy - */ - public static HostPortWaitStrategy forListeningPort() { - return new HostPortWaitStrategy(); - } - - /** - * Convenience method to return a WaitStrategy for an HTTP endpoint. - * - * @param path the path to check - * @return the WaitStrategy - * @see HttpWaitStrategy - */ - public static HttpWaitStrategy forHttp(String path) { - return new HttpWaitStrategy() - .forPath(path) - .forStatusCode(HttpURLConnection.HTTP_OK); - } - - /** - * Convenience method to return a WaitStrategy for an HTTPS endpoint. - * - * @param path the path to check - * @return the WaitStrategy - * @see HttpWaitStrategy - */ - public static HttpWaitStrategy forHttps(String path) { - return forHttp(path) - .usingTls(); - } -} diff --git a/core/src/main/java/org/testcontainers/containers/wait/WaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/WaitStrategy.java deleted file mode 100644 index 871c314a88d..00000000000 --- a/core/src/main/java/org/testcontainers/containers/wait/WaitStrategy.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.testcontainers.containers.wait; - -import org.testcontainers.containers.GenericContainer; - -import java.time.Duration; - -/** - * Approach to determine whether a container is ready. - * - * @author Pete Cornish {@literal } - */ -public interface WaitStrategy { - /** - * Wait until the container has started. - * - * @param container the container for which to wait - */ - void waitUntilReady(GenericContainer container); - - /** - * @param startupTimeout the duration for which to wait - * @return this - */ - WaitStrategy withStartupTimeout(Duration startupTimeout); -} diff --git a/core/src/main/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheck.java b/core/src/main/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheck.java new file mode 100644 index 00000000000..fbee9c2f9f4 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheck.java @@ -0,0 +1,38 @@ +package org.testcontainers.containers.wait.internal; + +import lombok.RequiredArgsConstructor; +import org.testcontainers.containers.ContainerState; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.Set; +import java.util.concurrent.Callable; + +/** + * Mechanism for testing that a socket is listening when run from the test host. + */ +@RequiredArgsConstructor +public class ExternalPortListeningCheck implements Callable { + + private final ContainerState containerState; + + private final Set externalLivenessCheckPorts; + + @Override + public Boolean call() { + String address = containerState.getHost(); + + externalLivenessCheckPorts + .parallelStream() + .forEach(externalPort -> { + try (Socket socket = new Socket()) { + InetSocketAddress inetSocketAddress = new InetSocketAddress(address, externalPort); + socket.connect(inetSocketAddress, 1000); + } catch (IOException e) { + throw new IllegalStateException("Socket not listening yet: " + externalPort); + } + }); + return true; + } +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/internal/InternalCommandPortListeningCheck.java b/core/src/main/java/org/testcontainers/containers/wait/internal/InternalCommandPortListeningCheck.java new file mode 100644 index 00000000000..aeedbea1e3f --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/internal/InternalCommandPortListeningCheck.java @@ -0,0 +1,65 @@ +package org.testcontainers.containers.wait.internal; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.containers.Container.ExecResult; +import org.testcontainers.containers.ExecInContainerPattern; +import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; + +import java.time.Duration; +import java.time.Instant; +import java.util.Set; + +/** + * Mechanism for testing that a socket is listening when run from the container being checked. + */ +@RequiredArgsConstructor +@Slf4j +public class InternalCommandPortListeningCheck implements java.util.concurrent.Callable { + + private final WaitStrategyTarget waitStrategyTarget; + + private final Set internalPorts; + + @Override + public Boolean call() { + StringBuilder command = new StringBuilder("while true; do ( true "); + + for (int internalPort : internalPorts) { + command.append(" && "); + command.append(" ("); + command.append(String.format("grep -i ':0*%x' /proc/net/tcp*", internalPort)); + command.append(" || "); + command.append(String.format("nc -vz -w 1 localhost %d", internalPort)); + command.append(" || "); + command.append(String.format("/bin/bash -c ' getLivenessCheckPorts() { + return waitStrategyTarget.getLivenessCheckPortNumbers(); + } + + /** + * @return the rate limiter to use + */ + protected RateLimiter getRateLimiter() { + return rateLimiter; + } + + /** + * Set the rate limiter being used + * + * @param rateLimiter rateLimiter + * @return this + */ + public WaitStrategy withRateLimiter(RateLimiter rateLimiter) { + this.rateLimiter = rateLimiter; + return this; + } +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/DockerHealthcheckWaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/DockerHealthcheckWaitStrategy.java new file mode 100644 index 00000000000..60df3cb9efc --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/DockerHealthcheckWaitStrategy.java @@ -0,0 +1,28 @@ +package org.testcontainers.containers.wait.strategy; + +import org.rnorth.ducttape.TimeoutException; +import org.rnorth.ducttape.unreliables.Unreliables; +import org.testcontainers.containers.ContainerLaunchException; + +import java.util.concurrent.TimeUnit; + +/** + * Wait strategy leveraging Docker's built-in healthcheck mechanism. + * + * @see https://docs.docker.com/engine/reference/builder/#healthcheck + */ +public class DockerHealthcheckWaitStrategy extends AbstractWaitStrategy { + + @Override + protected void waitUntilReady() { + try { + Unreliables.retryUntilTrue( + (int) startupTimeout.getSeconds(), + TimeUnit.SECONDS, + waitStrategyTarget::isHealthy + ); + } catch (TimeoutException e) { + throw new ContainerLaunchException("Timed out waiting for container to become healthy"); + } + } +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/HostPortWaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/HostPortWaitStrategy.java new file mode 100644 index 00000000000..5f7eb92818c --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/HostPortWaitStrategy.java @@ -0,0 +1,131 @@ +package org.testcontainers.containers.wait.strategy; + +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.awaitility.Awaitility; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.wait.internal.ExternalPortListeningCheck; +import org.testcontainers.containers.wait.internal.InternalCommandPortListeningCheck; + +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; + +/** + * Waits until a socket connection can be established on a port exposed or mapped by the container. + */ +@Slf4j +public class HostPortWaitStrategy extends AbstractWaitStrategy { + + private int[] ports; + + @Override + @SneakyThrows(InterruptedException.class) + protected void waitUntilReady() { + final Set externalLivenessCheckPorts; + if (this.ports == null || this.ports.length == 0) { + externalLivenessCheckPorts = getLivenessCheckPorts(); + if (externalLivenessCheckPorts.isEmpty()) { + if (log.isDebugEnabled()) { + log.debug( + "Liveness check ports of {} is empty. Not waiting.", + waitStrategyTarget.getContainerInfo().getName() + ); + } + return; + } + } else { + externalLivenessCheckPorts = + Arrays + .stream(this.ports) + .mapToObj(port -> waitStrategyTarget.getMappedPort(port)) + .collect(Collectors.toSet()); + } + + List exposedPorts = waitStrategyTarget.getExposedPorts(); + + final Set internalPorts = getInternalPorts(externalLivenessCheckPorts, exposedPorts); + + Callable internalCheck = new InternalCommandPortListeningCheck(waitStrategyTarget, internalPorts); + + Callable externalCheck = new ExternalPortListeningCheck( + waitStrategyTarget, + externalLivenessCheckPorts + ); + + try { + List> futures = EXECUTOR.invokeAll( + Arrays.asList( + // Blocking + () -> { + Instant now = Instant.now(); + Boolean result = internalCheck.call(); + log.debug( + "Internal port check {} for {} in {}", + Boolean.TRUE.equals(result) ? "passed" : "failed", + internalPorts, + Duration.between(now, Instant.now()) + ); + return result; + }, + // Polling + () -> { + Instant now = Instant.now(); + Awaitility + .await() + .pollInSameThread() + .pollInterval(Duration.ofMillis(100)) + .pollDelay(Duration.ZERO) + .failFast("container is no longer running", () -> !waitStrategyTarget.isRunning()) + .ignoreExceptions() + .forever() + .until(externalCheck); + + log.debug( + "External port check passed for {} mapped as {} in {}", + internalPorts, + externalLivenessCheckPorts, + Duration.between(now, Instant.now()) + ); + return true; + } + ), + startupTimeout.getSeconds(), + TimeUnit.SECONDS + ); + + for (Future future : futures) { + future.get(0, TimeUnit.SECONDS); + } + } catch (CancellationException | ExecutionException | TimeoutException e) { + throw new ContainerLaunchException( + "Timed out waiting for container port to open (" + + waitStrategyTarget.getHost() + + " ports: " + + externalLivenessCheckPorts + + " should be listening)" + ); + } + } + + private Set getInternalPorts(Set externalLivenessCheckPorts, List exposedPorts) { + return exposedPorts + .stream() + .filter(it -> externalLivenessCheckPorts.contains(waitStrategyTarget.getMappedPort(it))) + .collect(Collectors.toSet()); + } + + public HostPortWaitStrategy forPorts(int... ports) { + this.ports = ports; + return this; + } +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/HttpWaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/HttpWaitStrategy.java new file mode 100644 index 00000000000..59f6d9077fb --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/HttpWaitStrategy.java @@ -0,0 +1,423 @@ +package org.testcontainers.containers.wait.strategy; + +import com.google.common.base.Strings; +import com.google.common.io.BaseEncoding; +import lombok.extern.slf4j.Slf4j; +import org.rnorth.ducttape.TimeoutException; +import org.testcontainers.containers.ContainerLaunchException; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.Socket; +import java.net.URI; +import java.net.URL; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509ExtendedTrustManager; + +import static org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess; + +@Slf4j +public class HttpWaitStrategy extends AbstractWaitStrategy { + + /** + * Authorization HTTP header. + */ + private static final String HEADER_AUTHORIZATION = "Authorization"; + + /** + * Basic Authorization scheme prefix. + */ + private static final String AUTH_BASIC = "Basic "; + + private String path = "/"; + + private String method = "GET"; + + private Set statusCodes = new HashSet<>(); + + private boolean tlsEnabled; + + private String username; + + private String password; + + private final Map headers = new HashMap<>(); + + private Predicate responsePredicate; + + private Predicate statusCodePredicate = null; + + private Optional livenessPort = Optional.empty(); + + private Duration readTimeout = Duration.ofSeconds(1); + + private boolean allowInsecure; + + /** + * Waits for the given status code. + * + * @param statusCode the expected status code + * @return this + */ + public HttpWaitStrategy forStatusCode(int statusCode) { + statusCodes.add(statusCode); + return this; + } + + /** + * Waits for the status code to pass the given predicate + * @param statusCodePredicate The predicate to test the response against + * @return this + */ + public HttpWaitStrategy forStatusCodeMatching(Predicate statusCodePredicate) { + this.statusCodePredicate = statusCodePredicate; + return this; + } + + /** + * Waits for the given path. + * + * @param path the path to check + * @return this + */ + public HttpWaitStrategy forPath(String path) { + this.path = path; + return this; + } + + /** + * Wait for the given port. + * + * @param port the given port + * @return this + */ + public HttpWaitStrategy forPort(int port) { + this.livenessPort = Optional.of(port); + return this; + } + + /** + * Indicates that the status check should use HTTPS. + * + * @return this + */ + public HttpWaitStrategy usingTls() { + this.tlsEnabled = true; + return this; + } + + /** + * Indicates the HTTP method to use (GET by default). + * + * @param method the HTTP method. + * @return this + */ + public HttpWaitStrategy withMethod(String method) { + this.method = method; + return this; + } + + /** + * Indicates that HTTPS connection could use untrusted (self signed) certificate chains. + * + * @return this + */ + public HttpWaitStrategy allowInsecure() { + this.allowInsecure = true; + return this; + } + + /** + * Authenticate with HTTP Basic Authorization credentials. + * + * @param username the username + * @param password the password + * @return this + */ + public HttpWaitStrategy withBasicCredentials(String username, String password) { + this.username = username; + this.password = password; + return this; + } + + /** + * Add a custom HTTP Header to the call. + * @param name The HTTP Header name + * @param value The HTTP Header value + * @return this + */ + public HttpWaitStrategy withHeader(String name, String value) { + this.headers.put(name, value); + return this; + } + + /** + * Add multiple custom HTTP Headers to the call. + * @param headers Headers map of name/value + * @return this + */ + public HttpWaitStrategy withHeaders(Map headers) { + this.headers.putAll(headers); + return this; + } + + /** + * Set the HTTP connections read timeout. + * + * @param timeout the timeout (minimum 1 millisecond) + * @return this + */ + public HttpWaitStrategy withReadTimeout(Duration timeout) { + if (timeout.toMillis() < 1) { + throw new IllegalArgumentException("you cannot specify a value smaller than 1 ms"); + } + this.readTimeout = timeout; + return this; + } + + /** + * Waits for the response to pass the given predicate + * @param responsePredicate The predicate to test the response against + * @return this + */ + public HttpWaitStrategy forResponsePredicate(Predicate responsePredicate) { + this.responsePredicate = responsePredicate; + return this; + } + + @Override + protected void waitUntilReady() { + final String containerName = waitStrategyTarget.getContainerInfo().getName(); + + final Integer livenessCheckPort = livenessPort + .map(waitStrategyTarget::getMappedPort) + .orElseGet(() -> { + final Set livenessCheckPorts = getLivenessCheckPorts(); + if (livenessCheckPorts == null || livenessCheckPorts.isEmpty()) { + log.warn("{}: No exposed ports or mapped ports - cannot wait for status", containerName); + return -1; + } + return livenessCheckPorts.iterator().next(); + }); + + if (null == livenessCheckPort || -1 == livenessCheckPort) { + return; + } + final URI rawUri = buildLivenessUri(livenessCheckPort); + final String uri = rawUri.toString(); + + try { + // Un-map the port for logging + int originalPort = waitStrategyTarget + .getExposedPorts() + .stream() + .filter(exposedPort -> rawUri.getPort() == waitStrategyTarget.getMappedPort(exposedPort)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Target port " + rawUri.getPort() + " is not exposed")); + log.info( + "{}: Waiting for {} seconds for URL: {} (where port {} maps to container port {})", + containerName, + startupTimeout.getSeconds(), + uri, + rawUri.getPort(), + originalPort + ); + } catch (RuntimeException e) { + // do not allow a failure in logging to prevent progress, but log for diagnosis + log.warn("Unexpected error occurred - will proceed to try to wait anyway", e); + } + + // try to connect to the URL + try { + retryUntilSuccess( + (int) startupTimeout.getSeconds(), + TimeUnit.SECONDS, + () -> { + getRateLimiter() + .doWhenReady(() -> { + try { + final HttpURLConnection connection = openConnection(uri); + connection.setReadTimeout(Math.toIntExact(readTimeout.toMillis())); + + // authenticate + if (!Strings.isNullOrEmpty(username)) { + connection.setRequestProperty( + HEADER_AUTHORIZATION, + buildAuthString(username, password) + ); + connection.setUseCaches(false); + } + + // Add user configured headers + this.headers.forEach(connection::setRequestProperty); + connection.setRequestMethod(method); + connection.connect(); + + log.trace("Get response code {}", connection.getResponseCode()); + + // Choose the statusCodePredicate strategy depending on what we defined. + Predicate predicate; + if (statusCodes.isEmpty() && statusCodePredicate == null) { + // We have no status code and no predicate so we expect a 200 OK response code + predicate = responseCode -> HttpURLConnection.HTTP_OK == responseCode; + } else if (!statusCodes.isEmpty() && statusCodePredicate == null) { + // We use the default status predicate checker when we only have status codes + predicate = responseCode -> statusCodes.contains(responseCode); + } else if (statusCodes.isEmpty()) { + // We only have a predicate + predicate = statusCodePredicate; + } else { + // We have both predicate and status code + predicate = + statusCodePredicate.or(responseCode -> statusCodes.contains(responseCode)); + } + if (!predicate.test(connection.getResponseCode())) { + throw new RuntimeException( + String.format("HTTP response code was: %s", connection.getResponseCode()) + ); + } + + if (responsePredicate != null) { + String responseBody = getResponseBody(connection); + + log.trace("Get response {}", responseBody); + + if (!responsePredicate.test(responseBody)) { + throw new RuntimeException( + String.format("Response: %s did not match predicate", responseBody) + ); + } + } + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + return true; + } + ); + } catch (TimeoutException e) { + throw new ContainerLaunchException( + String.format( + "Timed out waiting for URL to be accessible (%s should return HTTP %s)", + uri, + statusCodes.isEmpty() ? HttpURLConnection.HTTP_OK : statusCodes + ), + e + ); + } + } + + private HttpURLConnection openConnection(final String uri) throws IOException, MalformedURLException { + if (tlsEnabled) { + final HttpsURLConnection connection = (HttpsURLConnection) new URL(uri).openConnection(); + if (allowInsecure) { + // Create a trust manager that does not validate certificate chains + // and trust all certificates + final TrustManager[] trustAllCerts = new TrustManager[] { + new X509ExtendedTrustManager() { + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + @Override + public void checkClientTrusted(final X509Certificate[] certs, final String authType) {} + + @Override + public void checkServerTrusted(final X509Certificate[] certs, final String authType) {} + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) {} + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) {} + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {} + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {} + }, + }; + + try { + // Create custom SSL context and set the "trust all certificates" trust manager + final SSLContext sc = SSLContext.getInstance("SSL"); + sc.init(new KeyManager[0], trustAllCerts, new SecureRandom()); + connection.setSSLSocketFactory(sc.getSocketFactory()); + } catch (final NoSuchAlgorithmException | KeyManagementException ex) { + throw new IOException("Unable to create custom SSL factory instance", ex); + } + } + + return connection; + } else { + return (HttpURLConnection) new URL(uri).openConnection(); + } + } + + /** + * Build the URI on which to check if the container is ready. + * + * @param livenessCheckPort the liveness port + * @return the liveness URI + */ + private URI buildLivenessUri(int livenessCheckPort) { + final String scheme = (tlsEnabled ? "https" : "http") + "://"; + final String host = waitStrategyTarget.getHost(); + + final String portSuffix; + if ((tlsEnabled && 443 == livenessCheckPort) || (!tlsEnabled && 80 == livenessCheckPort)) { + portSuffix = ""; + } else { + portSuffix = ":" + livenessCheckPort; + } + + return URI.create(scheme + host + portSuffix + path); + } + + /** + * @param username the username + * @param password the password + * @return a basic authentication string for the given credentials + */ + private String buildAuthString(String username, String password) { + return AUTH_BASIC + BaseEncoding.base64().encode((username + ":" + password).getBytes()); + } + + private String getResponseBody(HttpURLConnection connection) throws IOException { + BufferedReader reader; + if (200 <= connection.getResponseCode() && connection.getResponseCode() <= 299) { + reader = new BufferedReader(new InputStreamReader((connection.getInputStream()))); + } else { + reader = new BufferedReader(new InputStreamReader((connection.getErrorStream()))); + } + + StringBuilder builder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + } + return builder.toString(); + } +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/LogMessageWaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/LogMessageWaitStrategy.java new file mode 100644 index 00000000000..7a095d888f2 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/LogMessageWaitStrategy.java @@ -0,0 +1,61 @@ +package org.testcontainers.containers.wait.strategy; + +import com.github.dockerjava.api.command.LogContainerCmd; +import lombok.SneakyThrows; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.output.FrameConsumerResultCallback; +import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.containers.output.WaitingConsumer; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Predicate; + +public class LogMessageWaitStrategy extends AbstractWaitStrategy { + + private String regEx; + + private int times = 1; + + @Override + @SneakyThrows(IOException.class) + protected void waitUntilReady() { + WaitingConsumer waitingConsumer = new WaitingConsumer(); + + LogContainerCmd cmd = waitStrategyTarget + .getDockerClient() + .logContainerCmd(waitStrategyTarget.getContainerId()) + .withFollowStream(true) + .withSince(0) + .withStdOut(true) + .withStdErr(true); + + try (FrameConsumerResultCallback callback = new FrameConsumerResultCallback()) { + callback.addConsumer(OutputFrame.OutputType.STDOUT, waitingConsumer); + callback.addConsumer(OutputFrame.OutputType.STDERR, waitingConsumer); + + cmd.exec(callback); + + Predicate waitPredicate = outputFrame -> { + // (?s) enables line terminator matching (equivalent to Pattern.DOTALL) + return outputFrame.getUtf8String().matches("(?s)" + regEx); + }; + try { + waitingConsumer.waitUntil(waitPredicate, startupTimeout.getSeconds(), TimeUnit.SECONDS, times); + } catch (TimeoutException e) { + throw new ContainerLaunchException("Timed out waiting for log output matching '" + regEx + "'"); + } + } + } + + public LogMessageWaitStrategy withRegEx(String regEx) { + this.regEx = regEx; + return this; + } + + public LogMessageWaitStrategy withTimes(int times) { + this.times = times; + return this; + } +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/ShellStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/ShellStrategy.java new file mode 100644 index 00000000000..6c72daa6935 --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/ShellStrategy.java @@ -0,0 +1,32 @@ +package org.testcontainers.containers.wait.strategy; + +import org.rnorth.ducttape.TimeoutException; +import org.rnorth.ducttape.unreliables.Unreliables; +import org.testcontainers.containers.ContainerLaunchException; + +import java.util.concurrent.TimeUnit; + +public class ShellStrategy extends AbstractWaitStrategy { + + private String command; + + public ShellStrategy withCommand(String command) { + this.command = command; + return this; + } + + @Override + protected void waitUntilReady() { + try { + Unreliables.retryUntilTrue( + (int) startupTimeout.getSeconds(), + TimeUnit.SECONDS, + () -> waitStrategyTarget.execInContainer("/bin/sh", "-c", this.command).getExitCode() == 0 + ); + } catch (TimeoutException e) { + throw new ContainerLaunchException( + "Timed out waiting for container to execute `" + this.command + "` successfully." + ); + } + } +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/Wait.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/Wait.java new file mode 100644 index 00000000000..5a70c7f2fbc --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/Wait.java @@ -0,0 +1,89 @@ +package org.testcontainers.containers.wait.strategy; + +/** + * Convenience class with logic for building common {@link WaitStrategy} instances. + * + */ +public class Wait { + + /** + * Convenience method to return the default WaitStrategy. + * + * @return a WaitStrategy + */ + public static WaitStrategy defaultWaitStrategy() { + return forListeningPort(); + } + + /** + * Convenience method to return a WaitStrategy for an exposed or mapped port. + * + * @return the WaitStrategy + * @see HostPortWaitStrategy + */ + public static HostPortWaitStrategy forListeningPort() { + return new HostPortWaitStrategy(); + } + + /** + * Convenience method to return a WaitStrategy for exposed or mapped ports. + * + * @param ports the port to check + * @return the WaitStrategy + */ + public static HostPortWaitStrategy forListeningPorts(int... ports) { + return new HostPortWaitStrategy().forPorts(ports); + } + + /** + * Convenience method to return a WaitStrategy for an HTTP endpoint. + * + * @param path the path to check + * @return the WaitStrategy + * @see HttpWaitStrategy + */ + public static HttpWaitStrategy forHttp(String path) { + return new HttpWaitStrategy().forPath(path); + } + + /** + * Convenience method to return a WaitStrategy for an HTTPS endpoint. + * + * @param path the path to check + * @return the WaitStrategy + * @see HttpWaitStrategy + */ + public static HttpWaitStrategy forHttps(String path) { + return forHttp(path).usingTls(); + } + + /** + * Convenience method to return a WaitStrategy for log messages. + * + * @param regex the regex pattern to check for + * @param times the number of times the pattern is expected + * @return LogMessageWaitStrategy + */ + public static LogMessageWaitStrategy forLogMessage(String regex, int times) { + return new LogMessageWaitStrategy().withRegEx(regex).withTimes(times); + } + + /** + * Convenience method to return a WaitStrategy leveraging Docker's built-in healthcheck. + * + * @return DockerHealthcheckWaitStrategy + */ + public static DockerHealthcheckWaitStrategy forHealthcheck() { + return new DockerHealthcheckWaitStrategy(); + } + + /** + * Convenience method to return a WaitStrategy for a shell command. + * + * @param command the command to run + * @return ShellStrategy + */ + public static ShellStrategy forSuccessfulCommand(String command) { + return new ShellStrategy().withCommand(command); + } +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitAllStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitAllStrategy.java new file mode 100644 index 00000000000..5c7530fc67f --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitAllStrategy.java @@ -0,0 +1,98 @@ +package org.testcontainers.containers.wait.strategy; + +import org.rnorth.ducttape.timeouts.Timeouts; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class WaitAllStrategy implements WaitStrategy { + + public enum Mode { + /** + * This is the default mode: The timeout of the {@link WaitAllStrategy strategy} is applied to each individual + * strategy, so that the container waits maximum for + * {@link org.testcontainers.containers.wait.strategy.WaitAllStrategy#timeout}. + */ + WITH_OUTER_TIMEOUT, + + /** + * Using this mode triggers the following behaviour: The outer timeout is disabled and the outer enclosing + * strategy waits for all inner strategies according to their timeout. Once set, it disables + * {@link org.testcontainers.containers.wait.strategy.WaitAllStrategy#withStartupTimeout(java.time.Duration)} method, + * as it would overwrite inner timeouts. + */ + WITH_INDIVIDUAL_TIMEOUTS_ONLY, + + /** + * This is the original mode of this strategy: The inner strategies wait with their preconfigured timeout + * individually and the wait all strategy kills them, if the outer limit is reached. + */ + WITH_MAXIMUM_OUTER_TIMEOUT, + } + + private final Mode mode; + + private final List strategies = new ArrayList<>(); + + private Duration timeout = Duration.ofSeconds(30); + + public WaitAllStrategy() { + this(Mode.WITH_OUTER_TIMEOUT); + } + + public WaitAllStrategy(Mode mode) { + this.mode = mode; + } + + @Override + public void waitUntilReady(WaitStrategyTarget waitStrategyTarget) { + if (mode == Mode.WITH_INDIVIDUAL_TIMEOUTS_ONLY) { + waitUntilNestedStrategiesAreReady(waitStrategyTarget); + } else { + Timeouts.doWithTimeout( + (int) timeout.toMillis(), + TimeUnit.MILLISECONDS, + () -> { + waitUntilNestedStrategiesAreReady(waitStrategyTarget); + } + ); + } + } + + private void waitUntilNestedStrategiesAreReady(WaitStrategyTarget waitStrategyTarget) { + for (WaitStrategy strategy : strategies) { + strategy.waitUntilReady(waitStrategyTarget); + } + } + + public WaitAllStrategy withStrategy(WaitStrategy strategy) { + if (mode == Mode.WITH_OUTER_TIMEOUT) { + applyStartupTimeout(strategy); + } + + this.strategies.add(strategy); + return this; + } + + @Override + public WaitAllStrategy withStartupTimeout(Duration startupTimeout) { + if (mode == Mode.WITH_INDIVIDUAL_TIMEOUTS_ONLY) { + throw new IllegalStateException( + String.format( + "Changing startup timeout is not supported with mode %s", + Mode.WITH_INDIVIDUAL_TIMEOUTS_ONLY + ) + ); + } + + this.timeout = startupTimeout; + strategies.forEach(this::applyStartupTimeout); + return this; + } + + private void applyStartupTimeout(WaitStrategy childStrategy) { + childStrategy.withStartupTimeout(this.timeout); + } +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategy.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategy.java new file mode 100644 index 00000000000..ff371cf775f --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategy.java @@ -0,0 +1,9 @@ +package org.testcontainers.containers.wait.strategy; + +import java.time.Duration; + +public interface WaitStrategy { + void waitUntilReady(WaitStrategyTarget waitStrategyTarget); + + WaitStrategy withStartupTimeout(Duration startupTimeout); +} diff --git a/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategyTarget.java b/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategyTarget.java new file mode 100644 index 00000000000..778c252009b --- /dev/null +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategyTarget.java @@ -0,0 +1,21 @@ +package org.testcontainers.containers.wait.strategy; + +import org.testcontainers.containers.ContainerState; + +import java.util.Set; +import java.util.stream.Collectors; + +public interface WaitStrategyTarget extends ContainerState { + /** + * @return the ports on which to check if the container is ready + */ + default Set getLivenessCheckPortNumbers() { + final Set result = getExposedPorts() + .stream() + .map(this::getMappedPort) + .distinct() + .collect(Collectors.toSet()); + result.addAll(getBoundPortNumbers()); + return result; + } +} diff --git a/core/src/main/java/org/testcontainers/core/CreateContainerCmdModifier.java b/core/src/main/java/org/testcontainers/core/CreateContainerCmdModifier.java new file mode 100644 index 00000000000..fb45da4bc30 --- /dev/null +++ b/core/src/main/java/org/testcontainers/core/CreateContainerCmdModifier.java @@ -0,0 +1,13 @@ +package org.testcontainers.core; + +import com.github.dockerjava.api.command.CreateContainerCmd; + +/** + * Callback interface that can be used to customize a {@link CreateContainerCmd}. + */ +public interface CreateContainerCmdModifier { + /** + * Callback to modify a {@link CreateContainerCmd} instance. + */ + CreateContainerCmd modify(CreateContainerCmd createContainerCmd); +} diff --git a/core/src/main/java/org/testcontainers/dockerclient/AuditLoggingDockerClient.java b/core/src/main/java/org/testcontainers/dockerclient/AuditLoggingDockerClient.java new file mode 100644 index 00000000000..3f6c5c3e1c8 --- /dev/null +++ b/core/src/main/java/org/testcontainers/dockerclient/AuditLoggingDockerClient.java @@ -0,0 +1,152 @@ +package org.testcontainers.dockerclient; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.CreateContainerCmd; +import com.github.dockerjava.api.command.CreateNetworkCmd; +import com.github.dockerjava.api.command.KillContainerCmd; +import com.github.dockerjava.api.command.RemoveContainerCmd; +import com.github.dockerjava.api.command.RemoveNetworkCmd; +import com.github.dockerjava.api.command.StartContainerCmd; +import com.github.dockerjava.api.command.StopContainerCmd; +import com.github.dockerjava.api.command.SyncDockerCmd; +import lombok.experimental.Delegate; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.testcontainers.utility.AuditLogger; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.util.function.BiConsumer; + +/** + * Wrapper for {@link DockerClient} to facilitate 'audit logging' of potentially destruction actions using + * {@link org.testcontainers.utility.AuditLogger}. + * + */ +@Slf4j +@SuppressWarnings("unchecked") +class AuditLoggingDockerClient implements DockerClient { + + @Delegate(excludes = InterceptedMethods.class) + private final DockerClient wrappedClient; + + public AuditLoggingDockerClient(DockerClient wrappedClient) { + this.wrappedClient = wrappedClient; + } + + @Override + public CreateContainerCmd createContainerCmd(@NotNull String image) { + return wrappedCommand( + CreateContainerCmd.class, + wrappedClient.createContainerCmd(image), + (cmd, res) -> AuditLogger.doLog("CREATE", image, res.getId(), cmd), + (cmd, e) -> AuditLogger.doLog("CREATE", image, null, cmd, e) + ); + } + + @Override + public StartContainerCmd startContainerCmd(@NotNull String containerId) { + return wrappedCommand( + StartContainerCmd.class, + wrappedClient.startContainerCmd(containerId), + (cmd, res) -> AuditLogger.doLog("START", null, containerId, cmd), + (cmd, e) -> AuditLogger.doLog("START", null, containerId, cmd, e) + ); + } + + @Override + public RemoveContainerCmd removeContainerCmd(@NotNull String containerId) { + return wrappedCommand( + RemoveContainerCmd.class, + wrappedClient.removeContainerCmd(containerId), + (cmd, res) -> AuditLogger.doLog("REMOVE", null, containerId, cmd), + (cmd, e) -> AuditLogger.doLog("REMOVE", null, containerId, cmd, e) + ); + } + + @Override + public StopContainerCmd stopContainerCmd(@NotNull String containerId) { + return wrappedCommand( + StopContainerCmd.class, + wrappedClient.stopContainerCmd(containerId), + (cmd, res) -> AuditLogger.doLog("STOP", null, containerId, cmd), + (cmd, e) -> AuditLogger.doLog("STOP", null, containerId, cmd, e) + ); + } + + @Override + public KillContainerCmd killContainerCmd(@NotNull String containerId) { + return wrappedCommand( + KillContainerCmd.class, + wrappedClient.killContainerCmd(containerId), + (cmd, res) -> AuditLogger.doLog("KILL", null, containerId, cmd), + (cmd, e) -> AuditLogger.doLog("KILL", null, containerId, cmd, e) + ); + } + + @Override + public CreateNetworkCmd createNetworkCmd() { + return wrappedCommand( + CreateNetworkCmd.class, + wrappedClient.createNetworkCmd(), + (cmd, res) -> AuditLogger.doLog("CREATE_NETWORK", null, null, cmd), + (cmd, e) -> AuditLogger.doLog("CREATE_NETWORK", null, null, cmd, e) + ); + } + + @Override + public RemoveNetworkCmd removeNetworkCmd(@NotNull String networkId) { + return wrappedCommand( + RemoveNetworkCmd.class, + wrappedClient.removeNetworkCmd(networkId), + (cmd, res) -> AuditLogger.doLog("REMOVE_NETWORK", null, null, cmd), + (cmd, e) -> AuditLogger.doLog("REMOVE_NETWORK", null, null, cmd, e) + ); + } + + private , R> T wrappedCommand( + Class clazz, + T cmd, + BiConsumer successConsumer, + BiConsumer failureConsumer + ) { + return (T) Proxy.newProxyInstance( + clazz.getClassLoader(), + new Class[] { clazz }, + (proxy, method, args) -> { + if (method.getName().equals("exec")) { + try { + R r = (R) method.invoke(cmd, args); + successConsumer.accept(cmd, r); + return r; + } catch (Exception e) { + if (e instanceof InvocationTargetException && e.getCause() instanceof Exception) { + e = (Exception) e.getCause(); + } + failureConsumer.accept(cmd, e); + throw e; + } + } else { + return method.invoke(cmd, args); + } + } + ); + } + + @SuppressWarnings("unused") + private interface InterceptedMethods { + CreateContainerCmd createContainerCmd(String image); + + StartContainerCmd startContainerCmd(String containerId); + + RemoveContainerCmd removeContainerCmd(String containerId); + + StopContainerCmd stopContainerCmd(String containerId); + + KillContainerCmd killContainerCmd(String containerId); + + CreateNetworkCmd createNetworkCmd(); + + RemoveNetworkCmd removeNetworkCmd(String networkId); + } +} diff --git a/core/src/main/java/org/testcontainers/dockerclient/AuthDelegatingDockerClientConfig.java b/core/src/main/java/org/testcontainers/dockerclient/AuthDelegatingDockerClientConfig.java new file mode 100644 index 00000000000..5713d054e13 --- /dev/null +++ b/core/src/main/java/org/testcontainers/dockerclient/AuthDelegatingDockerClientConfig.java @@ -0,0 +1,50 @@ +package org.testcontainers.dockerclient; + +import com.github.dockerjava.api.model.AuthConfig; +import com.github.dockerjava.core.DockerClientConfig; +import com.github.dockerjava.core.DockerClientConfigDelegate; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.utility.AuthConfigUtil; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.RegistryAuthLocator; + +/** + * Facade implementation for {@link DockerClientConfig} which overrides how authentication + * configuration is obtained. A delegate {@link DockerClientConfig} will be called first + * to try and obtain auth credentials, but after that {@link RegistryAuthLocator} will be + * used to try and improve the auth resolution (e.g. using credential helpers). + * + * TODO move to docker-java + */ +@Slf4j +class AuthDelegatingDockerClientConfig extends DockerClientConfigDelegate { + + public AuthDelegatingDockerClientConfig(DockerClientConfig delegate) { + super(delegate); + } + + @Override + public AuthConfig effectiveAuthConfig(String imageName) { + // allow docker-java auth config to be used as a fallback + AuthConfig fallbackAuthConfig; + try { + fallbackAuthConfig = super.effectiveAuthConfig(imageName); + } catch (Exception e) { + log.debug( + "Delegate call to effectiveAuthConfig failed with cause: '{}'. " + + "Resolution of auth config will continue using RegistryAuthLocator.", + e.getMessage() + ); + fallbackAuthConfig = new AuthConfig(); + } + + // try and obtain more accurate auth config using our resolution + final DockerImageName parsed = DockerImageName.parse(imageName); + final AuthConfig effectiveAuthConfig = RegistryAuthLocator + .instance() + .lookupAuthConfig(parsed, fallbackAuthConfig); + + log.debug("Effective auth config [{}]", AuthConfigUtil.toSafeString(effectiveAuthConfig)); + return effectiveAuthConfig; + } +} diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerClientConfigUtils.java b/core/src/main/java/org/testcontainers/dockerclient/DockerClientConfigUtils.java index 979b8f93a4e..7206a7e1452 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/DockerClientConfigUtils.java +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerClientConfigUtils.java @@ -1,13 +1,14 @@ package org.testcontainers.dockerclient; -import com.github.dockerjava.core.DockerClientConfig; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang3.StringUtils; import org.testcontainers.DockerClientFactory; import java.io.File; +import java.net.URI; import java.util.Optional; +import java.util.concurrent.TimeUnit; @Slf4j public class DockerClientConfigUtils { @@ -16,38 +17,51 @@ public class DockerClientConfigUtils { public static final boolean IN_A_CONTAINER = new File("/.dockerenv").exists(); @Getter(lazy = true) - private static final Optional detectedDockerHostIp = Optional - .of(IN_A_CONTAINER) - .filter(it -> it) - .map(file -> DockerClientFactory.instance().runInsideDocker( + private static final Optional defaultGateway = Optional + .ofNullable( + DockerClientFactory + .instance() + .runInsideDocker( cmd -> cmd.withCmd("sh", "-c", "ip route|awk '/default/ { print $3 }'"), (client, id) -> { try { - return client.logContainerCmd(id) - .withStdOut(true) - .exec(new LogToStringContainerCallback()) - .toString(); + LogToStringContainerCallback loggingCallback = new LogToStringContainerCallback(); + client + .logContainerCmd(id) + .withStdOut(true) + .withFollowStream(true) + .exec(loggingCallback) + .awaitStarted(); + loggingCallback.awaitCompletion(3, TimeUnit.SECONDS); + return loggingCallback.toString(); } catch (Exception e) { log.warn("Can't parse the default gateway IP", e); return null; } } - )) - .map(StringUtils::trimToEmpty) - .filter(StringUtils::isNotBlank); + ) + ) + .map(StringUtils::trimToEmpty) + .filter(StringUtils::isNotBlank); - public static String getDockerHostIpAddress(DockerClientConfig config) { - return getDetectedDockerHostIp().orElseGet(() -> { - switch (config.getDockerHost().getScheme()) { - case "http": - case "https": - case "tcp": - return config.getDockerHost().getHost(); - case "unix": - return "localhost"; - default: - return null; - } - }); + /** + * @deprecated use {@link DockerClientProviderStrategy#getDockerHostIpAddress()} + */ + @Deprecated + public static String getDockerHostIpAddress(URI dockerHost) { + switch (dockerHost.getScheme()) { + case "http": + case "https": + case "tcp": + return dockerHost.getHost(); + case "unix": + case "npipe": + if (IN_A_CONTAINER) { + return getDefaultGateway().orElse("localhost"); + } + return "localhost"; + default: + return null; + } } } diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java index 7c1a65525c9..7b0aaafc169 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java @@ -1,45 +1,223 @@ package org.testcontainers.dockerclient; import com.github.dockerjava.api.DockerClient; -import com.github.dockerjava.core.DockerClientBuilder; -import com.github.dockerjava.core.DockerClientConfig; -import com.github.dockerjava.netty.NettyDockerCmdExecFactory; +import com.github.dockerjava.api.model.Info; +import com.github.dockerjava.api.model.Network; +import com.github.dockerjava.core.DefaultDockerClientConfig; +import com.github.dockerjava.core.DockerClientImpl; +import com.github.dockerjava.core.RemoteApiVersion; +import com.github.dockerjava.transport.DockerHttpClient; +import com.github.dockerjava.transport.NamedPipeSocket; +import com.github.dockerjava.transport.SSLConfig; +import com.github.dockerjava.transport.UnixSocket; +import com.github.dockerjava.zerodep.ZerodepDockerHttpClient; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Throwables; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.awaitility.Awaitility; import org.jetbrains.annotations.Nullable; +import org.rnorth.ducttape.TimeoutException; import org.rnorth.ducttape.ratelimits.RateLimiter; import org.rnorth.ducttape.ratelimits.RateLimiterBuilder; import org.rnorth.ducttape.unreliables.Unreliables; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.UnstableAPI; +import org.testcontainers.utility.TestcontainersConfiguration; +import java.io.File; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.URI; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.time.Duration; import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.net.SocketFactory; /** * Mechanism to find a viable Docker client configuration according to the host system environment. + *

+ * The order is: + *

    + *
  • {@code TestcontainersHostPropertyClientProviderStrategy}
  • + *
  • {@code EnvironmentAndSystemPropertyClientProviderStrategy}
  • + *
  • Persistable {@code DockerClientProviderStrategy} in ~/.testcontainers.properties
  • + *
  • Other strategies order by priority
  • + *
*/ +@Slf4j public abstract class DockerClientProviderStrategy { - protected DockerClient client; - protected DockerClientConfig config; + @Getter(lazy = true) + private final DockerClient dockerClient = getClientForConfig(getTransportConfig()); + + private String dockerHostIpAddress; + + @Getter + private Info info; + + private final RateLimiter PING_RATE_LIMITER = RateLimiterBuilder + .newBuilder() + .withRate(10, TimeUnit.SECONDS) + .withConstantThroughput() + .build(); + + private static final AtomicBoolean FAIL_FAST_ALWAYS = new AtomicBoolean(false); + + /** + * @return a short textual description of the strategy + */ + public abstract String getDescription(); + + protected boolean isApplicable() { + return true; + } + + protected boolean isPersistable() { + return true; + } - private static final RateLimiter PING_RATE_LIMITER = RateLimiterBuilder.newBuilder() - .withRate(2, TimeUnit.SECONDS) - .withConstantThroughput() - .build(); + public boolean allowUserOverrides() { + return true; + } + + /** + /* @return the path under which the Docker unix socket is reachable relative to the Docker daemon + */ + public String getRemoteDockerUnixSocketPath() { + return null; + } + + /** + * @return highest to lowest priority value + */ + protected int getPriority() { + return 0; + } /** * @throws InvalidConfigurationException if this strategy fails */ - public abstract void test() throws InvalidConfigurationException; + public abstract TransportConfig getTransportConfig() throws InvalidConfigurationException; /** - * @return a short textual description of the strategy + * @return a usable, tested, Docker client configuration for the host system environment + * + * @deprecated use {@link #getDockerClient()} */ - public abstract String getDescription(); + @Deprecated + public DockerClient getClient() { + DockerClient dockerClient = getDockerClient(); + try { + Unreliables.retryUntilSuccess( + 30, + TimeUnit.SECONDS, + () -> { + return PING_RATE_LIMITER.getWhenReady(() -> { + log.debug("Pinging docker daemon..."); + dockerClient.pingCmd().exec(); + log.debug("Pinged"); + return true; + }); + } + ); + } catch (TimeoutException e) { + IOUtils.closeQuietly(dockerClient); + throw e; + } + return dockerClient; + } - protected static final Logger LOGGER = LoggerFactory.getLogger(DockerClientProviderStrategy.class); + /** + * TODO we should consider moving this to docker-java at some point + */ + @UnstableAPI + protected boolean test() { + TransportConfig transportConfig = getTransportConfig(); + URI dockerHost = transportConfig.getDockerHost(); + + Callable socketProvider; + SocketAddress socketAddress; + switch (dockerHost.getScheme()) { + case "tcp": + case "http": + case "https": + SocketFactory socketFactory = SocketFactory.getDefault(); + SSLConfig sslConfig = transportConfig.getSslConfig(); + if (sslConfig != null) { + try { + socketFactory = sslConfig.getSSLContext().getSocketFactory(); + } catch ( + KeyManagementException + | UnrecoverableKeyException + | NoSuchAlgorithmException + | KeyStoreException e + ) { + log.warn("Exception while creating SSLSocketFactory", e); + return false; + } + } + socketProvider = socketFactory::createSocket; + socketAddress = new InetSocketAddress(dockerHost.getHost(), dockerHost.getPort()); + break; + case "unix": + case "npipe": + if (!new File(dockerHost.getPath()).exists()) { + log.debug("DOCKER_HOST socket file '{}' does not exist", dockerHost.getPath()); + return false; + } + socketProvider = + () -> { + switch (dockerHost.getScheme()) { + case "unix": + return UnixSocket.get(dockerHost.getPath()); + case "npipe": + return new NamedPipeSocket(dockerHost.getPath()); + default: + throw new IllegalStateException("Unexpected scheme " + dockerHost.getScheme()); + } + }; + socketAddress = new InetSocketAddress("localhost", 2375); + break; + default: + log.warn("Unknown DOCKER_HOST scheme {}, skipping the strategy test...", dockerHost.getScheme()); + return true; + } + + try (Socket socket = socketProvider.call()) { + Awaitility + .await() + .atMost(TestcontainersConfiguration.getInstance().getClientPingTimeout(), TimeUnit.SECONDS) // timeout after configured duration + .pollInterval(Duration.ofMillis(200)) // check state every 200ms + .pollDelay(Duration.ofSeconds(0)) // start checking immediately + .untilAsserted(() -> socket.connect(socketAddress)); + return true; + } catch (Exception e) { + log.warn("DOCKER_HOST {} is not listening", dockerHost, e); + return false; + } + } /** * Determine the right DockerClientConfig to use for building clients by trial-and-error. @@ -47,86 +225,258 @@ public abstract class DockerClientProviderStrategy { * @return a working DockerClientConfig, as determined by successful execution of a ping command */ public static DockerClientProviderStrategy getFirstValidStrategy(List strategies) { + if (FAIL_FAST_ALWAYS.get()) { + throw new IllegalStateException( + "Previous attempts to find a Docker environment failed. Will not retry. Please see logs and check configuration" + ); + } + List configurationFailures = new ArrayList<>(); + List allStrategies = new ArrayList<>(); - for (DockerClientProviderStrategy strategy : strategies) { - try { - strategy.test(); - LOGGER.info("Looking for Docker environment. Tried {}", strategy.getDescription()); - return strategy; - } catch (Exception | ExceptionInInitializerError | NoClassDefFoundError e) { - @Nullable String throwableMessage = e.getMessage(); - @SuppressWarnings("ThrowableResultOfMethodCallIgnored") - Throwable rootCause = Throwables.getRootCause(e); - @Nullable String rootCauseMessage = rootCause.getMessage(); - - String failureDescription; - if (throwableMessage != null && throwableMessage.equals(rootCauseMessage)) { - failureDescription = String.format("%s: failed with exception %s (%s)", - strategy.getClass().getSimpleName(), - e.getClass().getSimpleName(), - throwableMessage); - } else { - failureDescription = String.format("%s: failed with exception %s (%s). Root cause %s (%s)", - strategy.getClass().getSimpleName(), - e.getClass().getSimpleName(), - throwableMessage, - rootCause.getClass().getSimpleName(), - rootCauseMessage - ); - } - configurationFailures.add(failureDescription); + // Manually enforce priority independent of priority property of strategy + allStrategies.add(new TestcontainersHostPropertyClientProviderStrategy()); + allStrategies.add(new EnvironmentAndSystemPropertyClientProviderStrategy()); + + // Next strategy to try out is the one configured using the Testcontainers configuration mechanism + loadConfiguredStrategy().ifPresent(allStrategies::add); + + // Finally, add all other strategies ordered by their internal priority + strategies + .stream() + .sorted(Comparator.comparing(DockerClientProviderStrategy::getPriority).reversed()) + .collect(Collectors.toCollection(() -> allStrategies)); - LOGGER.debug(failureDescription); + Predicate distinctStrategyClassPredicate = new Predicate() { + final Set> classes = new HashSet<>(); + + @Override + public boolean test(DockerClientProviderStrategy dockerClientProviderStrategy) { + return classes.add(dockerClientProviderStrategy.getClass()); } - } + }; - LOGGER.error("Could not find a valid Docker environment. Please check configuration. Attempted configurations were:"); - for (String failureMessage : configurationFailures) { - LOGGER.error(" " + failureMessage); - } - LOGGER.error("As no valid configuration was found, execution cannot continue"); + return allStrategies + .stream() + .filter(distinctStrategyClassPredicate) + .filter(DockerClientProviderStrategy::isApplicable) + .filter(strategy -> tryOutStrategy(configurationFailures, strategy)) + .findFirst() + .orElseThrow(() -> { + log.error( + "Could not find a valid Docker environment. Please check configuration. Attempted configurations were:\n" + + configurationFailures.stream().map(it -> "\t" + it).collect(Collectors.joining("\n")) + + "As no valid configuration was found, execution cannot continue.\n" + + "See https://java.testcontainers.org/on_failure.html for more details." + ); - throw new IllegalStateException("Could not find a valid Docker environment. Please see logs and check configuration"); + FAIL_FAST_ALWAYS.set(true); + return new IllegalStateException( + "Could not find a valid Docker environment. Please see logs and check configuration" + ); + }); } - /** - * @return a usable, tested, Docker client configuration for the host system environment - */ - public DockerClient getClient() { - return client; - } + private static boolean tryOutStrategy(List configurationFailures, DockerClientProviderStrategy strategy) { + try { + log.debug("Trying out strategy: {}", strategy.getClass().getSimpleName()); - protected DockerClient getClientForConfig(DockerClientConfig config) { - return DockerClientBuilder - .getInstance(config) - .withDockerCmdExecFactory(new NettyDockerCmdExecFactory()) - .build(); - } + if (!strategy.test()) { + log.debug("strategy {} did not pass the test", strategy.getClass().getSimpleName()); + return false; + } - protected void ping(DockerClient client, int timeoutInSeconds) { - Unreliables.retryUntilSuccess(timeoutInSeconds, TimeUnit.SECONDS, () -> { - return PING_RATE_LIMITER.getWhenReady(() -> { - LOGGER.debug("Pinging docker daemon..."); - client.pingCmd().exec(); - return true; - }); - }); + strategy.info = strategy.getDockerClient().infoCmd().exec(); + log.info("Found Docker environment with {}", strategy.getDescription()); + log.debug( + "Transport type: '{}', Docker host: '{}'", + TestcontainersConfiguration.getInstance().getTransportType(), + strategy.getTransportConfig().getDockerHost() + ); + + log.debug("Checking Docker OS type for {}", strategy.getDescription()); + String osType = strategy.getInfo().getOsType(); + if (StringUtils.isBlank(osType)) { + log.warn("Could not determine Docker OS type"); + } else if (!osType.equals("linux")) { + log.warn("{} is currently not supported", osType); + throw new InvalidConfigurationException(osType + " containers are currently not supported"); + } + + if (strategy.isPersistable()) { + TestcontainersConfiguration + .getInstance() + .updateUserConfig("docker.client.strategy", strategy.getClass().getName()); + } + + return true; + } catch (Exception | ExceptionInInitializerError | NoClassDefFoundError e) { + @Nullable + String throwableMessage = e.getMessage(); + @SuppressWarnings("ThrowableResultOfMethodCallIgnored") + Throwable rootCause = Throwables.getRootCause(e); + @Nullable + String rootCauseMessage = rootCause.getMessage(); + + String failureDescription; + if (throwableMessage != null && throwableMessage.equals(rootCauseMessage)) { + failureDescription = + String.format( + "%s: failed with exception %s (%s)", + strategy.getClass().getSimpleName(), + e.getClass().getSimpleName(), + throwableMessage + ); + } else { + failureDescription = + String.format( + "%s: failed with exception %s (%s). Root cause %s (%s)", + strategy.getClass().getSimpleName(), + e.getClass().getSimpleName(), + throwableMessage, + rootCause.getClass().getSimpleName(), + rootCauseMessage + ); + } + configurationFailures.add(failureDescription); + + log.debug(failureDescription); + return false; + } } - public String getDockerHostIpAddress() { - return DockerClientConfigUtils.getDockerHostIpAddress(this.config); + private static Optional loadConfiguredStrategy() { + String configuredDockerClientStrategyClassName = TestcontainersConfiguration + .getInstance() + .getDockerClientStrategyClassName(); + + return Stream + .of(configuredDockerClientStrategyClassName) + .filter(Objects::nonNull) + .flatMap(it -> { + try { + Class strategyClass = (Class) Thread + .currentThread() + .getContextClassLoader() + .loadClass(it); + return Stream.of(strategyClass.newInstance()); + } catch (ClassNotFoundException e) { + log.warn( + "Can't instantiate a strategy from {} (ClassNotFoundException). " + + "This probably means that cached configuration refers to a client provider " + + "class that is not available in this version of Testcontainers. Other " + + "strategies will be tried instead.", + it + ); + return Stream.empty(); + } catch (InstantiationException | IllegalAccessException e) { + log.warn("Can't instantiate a strategy from {}", it, e); + return Stream.empty(); + } + }) + // Ignore persisted strategy if it's not persistable anymore + .filter(DockerClientProviderStrategy::isPersistable) + .peek(strategy -> { + log.info( + "Loaded {} from ~/.testcontainers.properties, will try it first", + strategy.getClass().getName() + ); + }) + .findFirst(); } + public static DockerClient getClientForConfig(TransportConfig transportConfig) { + final DockerHttpClient dockerHttpClient; + + String transportType = TestcontainersConfiguration.getInstance().getTransportType(); + switch (transportType) { + case "httpclient5": + dockerHttpClient = + new ZerodepDockerHttpClient.Builder() + .dockerHost(transportConfig.getDockerHost()) + .sslConfig(transportConfig.getSslConfig()) + .build(); + break; + default: + throw new IllegalArgumentException("Unknown transport type '" + transportType + "'"); + } - class InvalidConfigurationException extends RuntimeException { + DefaultDockerClientConfig.Builder configBuilder = DefaultDockerClientConfig + .createDefaultConfigBuilder() + .withDockerHost(transportConfig.getDockerHost().toString()); - public InvalidConfigurationException(String s) { - super(s); + Map headers = new HashMap<>(); + headers.put("x-tc-sid", DockerClientFactory.SESSION_ID); + headers.put("User-Agent", String.format("tc-java/%s", DockerClientFactory.TESTCONTAINERS_VERSION)); + + try { + if (configBuilder.build().getApiVersion() == RemoteApiVersion.UNKNOWN_VERSION) { + configBuilder.withApiVersion(RemoteApiVersion.VERSION_1_44); + } + DockerClient client = DockerClientImpl.getInstance( + new AuthDelegatingDockerClientConfig(configBuilder.build()), + new HeadersAddingDockerHttpClient(dockerHttpClient, headers) + ); + log.debug("Pinging Docker API version 1.44."); + client.pingCmd().exec(); + return client; + } catch (Exception ex) { + log.debug("Fallback to Docker API version 1.32."); + return DockerClientImpl.getInstance( + new AuthDelegatingDockerClientConfig( + configBuilder.withApiVersion(RemoteApiVersion.VERSION_1_32).build() + ), + new HeadersAddingDockerHttpClient(dockerHttpClient, headers) + ); + } + } + + public synchronized String getDockerHostIpAddress() { + if (dockerHostIpAddress == null) { + dockerHostIpAddress = + resolveDockerHostIpAddress( + getDockerClient(), + getTransportConfig().getDockerHost(), + allowUserOverrides() + ); } + return dockerHostIpAddress; + } - public InvalidConfigurationException(String message, Throwable cause) { - super(message, cause); + @VisibleForTesting + static String resolveDockerHostIpAddress(DockerClient client, URI dockerHost, boolean allowUserOverrides) { + if (allowUserOverrides) { + String hostOverride = System.getenv("TESTCONTAINERS_HOST_OVERRIDE"); + if (!StringUtils.isBlank(hostOverride)) { + return hostOverride; + } + } + + switch (dockerHost.getScheme()) { + case "http": + case "https": + case "tcp": + return dockerHost.getHost(); + case "unix": + case "npipe": + if (DockerClientConfigUtils.IN_A_CONTAINER) { + return client + .inspectNetworkCmd() + .withNetworkId("bridge") + .exec() + .getIpam() + .getConfig() + .stream() + .filter(it -> it.getGateway() != null) + .findAny() + .map(Network.Ipam.Config::getGateway) + .orElseGet(() -> { + return DockerClientConfigUtils.getDefaultGateway().orElse("localhost"); + }); + } + return "localhost"; + default: + return null; } } } diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerDesktopClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/DockerDesktopClientProviderStrategy.java new file mode 100644 index 00000000000..56fd0b59ee4 --- /dev/null +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerDesktopClientProviderStrategy.java @@ -0,0 +1,84 @@ +package org.testcontainers.dockerclient; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.SystemUtils; +import org.jetbrains.annotations.Nullable; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Optional; + +/** + * Look at the following paths: + *
    + *
  • Linux: ~/.docker/desktop/docker.sock
  • + *
  • MacOS: ~/.docker/run/docker.sock
  • + *
+ * + * @deprecated this class is used by the SPI and should not be used directly + */ +@Slf4j +@Deprecated +public class DockerDesktopClientProviderStrategy extends DockerClientProviderStrategy { + + public static final int PRIORITY = UnixSocketClientProviderStrategy.PRIORITY - 1; + + @Getter(lazy = true) + @Nullable + private final Path socketPath = resolveSocketPath(); + + private Path resolveSocketPath() { + Path linuxPath = Paths.get(System.getProperty("user.home")).resolve(".docker").resolve("desktop"); + return tryFolder(linuxPath) + .orElseGet(() -> { + Path macosPath = Paths.get(System.getProperty("user.home")).resolve(".docker").resolve("run"); + return tryFolder(macosPath).orElse(null); + }); + } + + @Override + public String getDescription() { + return "Docker accessed via Unix socket (" + getSocketPath() + ")"; + } + + @Override + public TransportConfig getTransportConfig() throws InvalidConfigurationException { + return TransportConfig.builder().dockerHost(URI.create("unix://" + getSocketPath().toString())).build(); + } + + @Override + protected int getPriority() { + return PRIORITY; + } + + @Override + protected boolean isPersistable() { + return false; + } + + @Override + public String getRemoteDockerUnixSocketPath() { + return "/var/run/docker.sock"; + } + + @Override + protected boolean isApplicable() { + return (SystemUtils.IS_OS_LINUX || SystemUtils.IS_OS_MAC) && this.socketPath != null; + } + + private Optional tryFolder(Path path) { + if (!Files.exists(path)) { + log.debug("'{}' does not exist.", path); + return Optional.empty(); + } + Path socketPath = path.resolve("docker.sock"); + if (!Files.exists(socketPath)) { + log.debug("'{}' does not exist.", socketPath); + return Optional.empty(); + } + return Optional.of(socketPath); + } +} diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerMachineClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/DockerMachineClientProviderStrategy.java index 59211e317a2..7c05a11c198 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/DockerMachineClientProviderStrategy.java +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerMachineClientProviderStrategy.java @@ -1,54 +1,89 @@ package org.testcontainers.dockerclient; -import com.github.dockerjava.core.DefaultDockerClientConfig; +import com.github.dockerjava.core.LocalDirectorySSLConfig; +import com.google.common.base.Preconditions; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import org.testcontainers.utility.CommandLine; import org.testcontainers.utility.DockerMachineClient; +import java.net.URI; import java.nio.file.Paths; import java.util.Arrays; import java.util.Optional; -import static com.google.common.base.Preconditions.checkArgument; - /** * Use Docker machine (if available on the PATH) to locate a Docker environment. + * + * @deprecated this class is used by the SPI and should not be used directly */ -public class DockerMachineClientProviderStrategy extends DockerClientProviderStrategy { - private static final String PING_TIMEOUT_DEFAULT = "30"; - private static final String PING_TIMEOUT_PROPERTY_NAME = "testcontainers.dockermachineprovider.timeout"; +@Slf4j +@Deprecated +public final class DockerMachineClientProviderStrategy extends DockerClientProviderStrategy { - @Override - public void test() throws InvalidConfigurationException { + @Getter(lazy = true) + private final TransportConfig transportConfig = resolveTransportConfig(); - try { - boolean installed = DockerMachineClient.instance().isInstalled(); - checkArgument(installed, "docker-machine executable was not found on PATH (" + Arrays.toString(CommandLine.getSystemPath()) + ")"); + private TransportConfig resolveTransportConfig() throws InvalidConfigurationException { + boolean installed = DockerMachineClient.instance().isInstalled(); + Preconditions.checkArgument( + installed, + "docker-machine executable was not found on PATH (" + Arrays.toString(CommandLine.getSystemPath()) + ")" + ); - Optional machineNameOptional = DockerMachineClient.instance().getDefaultMachine(); - checkArgument(machineNameOptional.isPresent(), "docker-machine is installed but no default machine could be found"); - String machineName = machineNameOptional.get(); + Optional machineNameOptional = DockerMachineClient.instance().getDefaultMachine(); + Preconditions.checkArgument( + machineNameOptional.isPresent(), + "docker-machine is installed but no default machine could be found" + ); + String machineName = machineNameOptional.get(); - LOGGER.info("Found docker-machine, and will use machine named {}", machineName); + log.info("Found docker-machine, and will use machine named {}", machineName); - DockerMachineClient.instance().ensureMachineRunning(machineName); + DockerMachineClient.instance().ensureMachineRunning(machineName); - String dockerDaemonIpAddress = DockerMachineClient.instance().getDockerDaemonIpAddress(machineName); + String dockerDaemonUrl = DockerMachineClient.instance().getDockerDaemonUrl(machineName); - LOGGER.info("Docker daemon IP address for docker machine {} is {}", machineName, dockerDaemonIpAddress); + log.info("Docker daemon URL for docker machine {} is {}", machineName, dockerDaemonUrl); + + return TransportConfig + .builder() + .dockerHost(URI.create(dockerDaemonUrl)) + .sslConfig( + new LocalDirectorySSLConfig( + Paths.get(System.getProperty("user.home") + "/.docker/machine/certs/").toString() + ) + ) + .build(); + } + + @Override + protected boolean isApplicable() { + boolean installed = DockerMachineClient.instance().isInstalled(); + if (!installed) { + log.info( + "docker-machine executable was not found on PATH ({})", + Arrays.toString(CommandLine.getSystemPath()) + ); + return false; + } - config = DefaultDockerClientConfig.createDefaultConfigBuilder() - .withDockerHost("tcp://" + dockerDaemonIpAddress + ":2376") - .withDockerTlsVerify(true) - .withDockerCertPath(Paths.get(System.getProperty("user.home") + "/.docker/machine/certs/").toString()) - .build(); - client = getClientForConfig(config); - } catch (Exception e) { - throw new InvalidConfigurationException(e.getMessage()); + Optional machineNameOptional = DockerMachineClient.instance().getDefaultMachine(); + if (!machineNameOptional.isPresent()) { + log.info("docker-machine is installed but no default machine could be found"); } - // If the docker-machine VM has started, the docker daemon may still not be ready. Retry pinging until it works. - final int timeout = Integer.parseInt(System.getProperty(PING_TIMEOUT_PROPERTY_NAME, PING_TIMEOUT_DEFAULT)); - ping(client, timeout); + return true; + } + + @Override + protected boolean isPersistable() { + return false; + } + + @Override + protected int getPriority() { + return EnvironmentAndSystemPropertyClientProviderStrategy.PRIORITY - 100; } @Override diff --git a/core/src/main/java/org/testcontainers/dockerclient/EnvironmentAndSystemPropertyClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/EnvironmentAndSystemPropertyClientProviderStrategy.java index ed482abc36b..969610373d1 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/EnvironmentAndSystemPropertyClientProviderStrategy.java +++ b/core/src/main/java/org/testcontainers/dockerclient/EnvironmentAndSystemPropertyClientProviderStrategy.java @@ -2,50 +2,89 @@ import com.github.dockerjava.core.DefaultDockerClientConfig; import com.github.dockerjava.core.DockerClientConfig; +import lombok.Getter; +import org.testcontainers.utility.TestcontainersConfiguration; + +import java.util.Optional; /** * Use environment variables and system properties (as supported by the underlying DockerClient DefaultConfigBuilder) * to try and locate a docker environment. + *

+ * Resolution order is: + *

    + *
  1. DOCKER_HOST env var
  2. + *
  3. docker.host in ~/.testcontainers.properties
  4. + *
+ * + * @deprecated this class is used by the SPI and should not be used directly */ -public class EnvironmentAndSystemPropertyClientProviderStrategy extends DockerClientProviderStrategy { - private static final String PING_TIMEOUT_DEFAULT = "10"; - private static final String PING_TIMEOUT_PROPERTY_NAME = "testcontainers.environmentprovider.timeout"; +@Deprecated +public final class EnvironmentAndSystemPropertyClientProviderStrategy extends DockerClientProviderStrategy { - @Override - public void test() throws InvalidConfigurationException { - - try { - // Try using environment variables - config = DefaultDockerClientConfig.createDefaultConfigBuilder().build(); - client = getClientForConfig(config); - - final int timeout = Integer.parseInt(System.getProperty(PING_TIMEOUT_PROPERTY_NAME, PING_TIMEOUT_DEFAULT)); - ping(client, timeout); - } catch (Exception | UnsatisfiedLinkError e) { - LOGGER.error("ping failed with configuration {} due to {}", getDescription(), e.toString(), e); - throw new InvalidConfigurationException("ping failed"); + public static final int PRIORITY = 100; + + private final DockerClientConfig dockerClientConfig; + + @Getter + private final boolean applicable; + + public EnvironmentAndSystemPropertyClientProviderStrategy() { + // use docker-java defaults if present, overridden if our own configuration is set + this(DefaultDockerClientConfig.createDefaultConfigBuilder()); + } + + EnvironmentAndSystemPropertyClientProviderStrategy(DefaultDockerClientConfig.Builder configBuilder) { + String dockerConfigSource = TestcontainersConfiguration + .getInstance() + .getEnvVarOrProperty("dockerconfig.source", "auto"); + + switch (dockerConfigSource) { + case "auto": + Optional dockerHost = getSetting("docker.host"); + dockerHost.ifPresent(configBuilder::withDockerHost); + applicable = dockerHost.isPresent(); + getSetting("docker.tls.verify").ifPresent(configBuilder::withDockerTlsVerify); + getSetting("docker.cert.path").ifPresent(configBuilder::withDockerCertPath); + break; + case "autoIgnoringUserProperties": + applicable = configBuilder.isDockerHostSetExplicitly(); + break; + default: + throw new InvalidConfigurationException("Invalid value for dockerconfig.source: " + dockerConfigSource); } - LOGGER.info("Found docker client settings from environment"); + dockerClientConfig = configBuilder.build(); + } + + private Optional getSetting(final String name) { + return Optional.ofNullable(TestcontainersConfiguration.getInstance().getEnvVarOrUserProperty(name, null)); } @Override - public String getDescription() { - return "Environment variables, system properties and defaults. Resolved: \n" + stringRepresentation(config); + public TransportConfig getTransportConfig() { + return TransportConfig + .builder() + .dockerHost(dockerClientConfig.getDockerHost()) + .sslConfig(dockerClientConfig.getSSLConfig()) + .build(); } - private String stringRepresentation(DockerClientConfig config) { + @Override + protected int getPriority() { + return PRIORITY; + } - if (config == null) { - return ""; - } + @Override + public String getDescription() { + return ( + "Environment variables, system properties and defaults. Resolved dockerHost=" + + dockerClientConfig.getDockerHost() + ); + } - return " dockerHost=" + config.getDockerHost() + "\n" + - " apiVersion='" + config.getApiVersion() + "'\n" + - " registryUrl='" + config.getRegistryUrl() + "'\n" + - " registryUsername='" + config.getRegistryUsername() + "'\n" + - " registryPassword='" + config.getRegistryPassword() + "'\n" + - " registryEmail='" + config.getRegistryEmail() + "'\n" + - " dockerConfig='" + config.toString() + "'\n"; + @Override + protected boolean isPersistable() { + return false; } } diff --git a/core/src/main/java/org/testcontainers/dockerclient/HeadersAddingDockerHttpClient.java b/core/src/main/java/org/testcontainers/dockerclient/HeadersAddingDockerHttpClient.java new file mode 100644 index 00000000000..7ff1b0b620c --- /dev/null +++ b/core/src/main/java/org/testcontainers/dockerclient/HeadersAddingDockerHttpClient.java @@ -0,0 +1,25 @@ +package org.testcontainers.dockerclient; + +import com.github.dockerjava.transport.DockerHttpClient; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import lombok.experimental.Delegate; + +import java.io.Closeable; +import java.util.Map; + +@RequiredArgsConstructor +@ToString +class HeadersAddingDockerHttpClient implements DockerHttpClient { + + @Delegate(types = Closeable.class) + final DockerHttpClient delegate; + + final Map headers; + + @Override + public Response execute(Request request) { + request = Request.builder().from(request).putAllHeaders(headers).build(); + return delegate.execute(request); + } +} diff --git a/core/src/main/java/org/testcontainers/dockerclient/InvalidConfigurationException.java b/core/src/main/java/org/testcontainers/dockerclient/InvalidConfigurationException.java new file mode 100644 index 00000000000..504378cb17e --- /dev/null +++ b/core/src/main/java/org/testcontainers/dockerclient/InvalidConfigurationException.java @@ -0,0 +1,15 @@ +package org.testcontainers.dockerclient; + +/** + * Exception to indicate that a {@link DockerClientProviderStrategy} fails. + */ +public class InvalidConfigurationException extends RuntimeException { + + public InvalidConfigurationException(String s) { + super(s); + } + + public InvalidConfigurationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/org/testcontainers/dockerclient/LogToStringContainerCallback.java b/core/src/main/java/org/testcontainers/dockerclient/LogToStringContainerCallback.java index e0ffce7c3a1..2c0716b65f0 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/LogToStringContainerCallback.java +++ b/core/src/main/java/org/testcontainers/dockerclient/LogToStringContainerCallback.java @@ -1,15 +1,20 @@ package org.testcontainers.dockerclient; +import com.github.dockerjava.api.async.ResultCallback; import com.github.dockerjava.api.model.Frame; -import com.github.dockerjava.core.command.LogContainerResultCallback; -public class LogToStringContainerCallback extends LogContainerResultCallback { +/** + * + * @deprecated use {@link ResultCallback.Adapter} + */ +@Deprecated +public class LogToStringContainerCallback extends ResultCallback.Adapter { + private final StringBuffer log = new StringBuffer(); @Override public void onNext(Frame frame) { log.append(new String(frame.getPayload())); - super.onNext(frame); } @Override diff --git a/core/src/main/java/org/testcontainers/dockerclient/NpipeSocketClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/NpipeSocketClientProviderStrategy.java new file mode 100644 index 00000000000..0e1f641fe8a --- /dev/null +++ b/core/src/main/java/org/testcontainers/dockerclient/NpipeSocketClientProviderStrategy.java @@ -0,0 +1,39 @@ +package org.testcontainers.dockerclient; + +import org.apache.commons.lang3.SystemUtils; + +import java.net.URI; + +/** + * + * @deprecated this class is used by the SPI and should not be used directly + */ +@Deprecated +public final class NpipeSocketClientProviderStrategy extends DockerClientProviderStrategy { + + protected static final String DOCKER_SOCK_PATH = "//./pipe/docker_engine"; + + private static final String SOCKET_LOCATION = "npipe://" + DOCKER_SOCK_PATH; + + public static final int PRIORITY = EnvironmentAndSystemPropertyClientProviderStrategy.PRIORITY - 20; + + @Override + public TransportConfig getTransportConfig() { + return TransportConfig.builder().dockerHost(URI.create(SOCKET_LOCATION)).build(); + } + + @Override + protected boolean isApplicable() { + return SystemUtils.IS_OS_WINDOWS; + } + + @Override + public String getDescription() { + return "local Npipe socket (" + SOCKET_LOCATION + ")"; + } + + @Override + protected int getPriority() { + return PRIORITY; + } +} diff --git a/core/src/main/java/org/testcontainers/dockerclient/ProxiedUnixSocketClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/ProxiedUnixSocketClientProviderStrategy.java deleted file mode 100644 index 7036e048b34..00000000000 --- a/core/src/main/java/org/testcontainers/dockerclient/ProxiedUnixSocketClientProviderStrategy.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.testcontainers.dockerclient; - -import org.rnorth.tcpunixsocketproxy.TcpToUnixSocketProxy; - -import java.io.File; - -public class ProxiedUnixSocketClientProviderStrategy extends UnixSocketClientProviderStrategy { - - @Override - public void test() throws InvalidConfigurationException { - - String osName = System.getProperty("os.name").toLowerCase(); - if (!osName.contains("mac") && !osName.contains("linux")) { - throw new InvalidConfigurationException("this strategy is only applicable to OS X and Linux"); - } - - TcpToUnixSocketProxy proxy = new TcpToUnixSocketProxy(new File(DOCKER_SOCK_PATH)); - - try { - int proxyPort = proxy.start().getPort(); - - config = tryConfiguration("tcp://localhost:" + proxyPort); - - LOGGER.info("Accessing unix domain socket via TCP proxy (" + DOCKER_SOCK_PATH + " via localhost:" + proxyPort + ")"); - } catch (Exception e) { - - proxy.stop(); - - throw new InvalidConfigurationException("ping failed", e); - } - - } - - @Override - public String getDescription() { - return "local Unix socket (via TCP proxy)"; - } - -} diff --git a/core/src/main/java/org/testcontainers/dockerclient/RootlessDockerClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/RootlessDockerClientProviderStrategy.java new file mode 100644 index 00000000000..a032c2b324d --- /dev/null +++ b/core/src/main/java/org/testcontainers/dockerclient/RootlessDockerClientProviderStrategy.java @@ -0,0 +1,100 @@ +package org.testcontainers.dockerclient; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.SystemUtils; +import org.jetbrains.annotations.Nullable; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Optional; + +/** + * + * @deprecated this class is used by the SPI and should not be used directly + */ +@Deprecated +@Slf4j +public final class RootlessDockerClientProviderStrategy extends DockerClientProviderStrategy { + + public static final int PRIORITY = UnixSocketClientProviderStrategy.PRIORITY + 1; + + @Getter(lazy = true) + @Nullable + private final Path socketPath = resolveSocketPath(); + + private Path resolveSocketPath() { + return tryEnv() + .orElseGet(() -> { + Path homePath = Paths.get(System.getProperty("user.home")).resolve(".docker").resolve("run"); + return tryFolder(homePath) + .orElseGet(() -> { + Path implicitPath = Paths.get("/run/user/" + LibC.INSTANCE.getuid()); + return tryFolder(implicitPath).orElse(null); + }); + }); + } + + private Optional tryEnv() { + String xdgRuntimeDir = System.getenv("XDG_RUNTIME_DIR"); + if (StringUtils.isBlank(xdgRuntimeDir)) { + log.debug("$XDG_RUNTIME_DIR is not set."); + return Optional.empty(); + } + Path path = Paths.get(xdgRuntimeDir); + if (!Files.exists(path)) { + log.debug("$XDG_RUNTIME_DIR is set to '{}' but the folder does not exist.", path); + return Optional.empty(); + } + Path socketPath = path.resolve("docker.sock"); + if (!Files.exists(socketPath)) { + log.debug("$XDG_RUNTIME_DIR is set but '{}' does not exist.", socketPath); + return Optional.empty(); + } + return Optional.of(socketPath); + } + + private Optional tryFolder(Path path) { + if (!Files.exists(path)) { + log.debug("'{}' does not exist.", path); + return Optional.empty(); + } + Path socketPath = path.resolve("docker.sock"); + if (!Files.exists(socketPath)) { + log.debug("'{}' does not exist.", socketPath); + return Optional.empty(); + } + return Optional.of(socketPath); + } + + @Override + public TransportConfig getTransportConfig() throws InvalidConfigurationException { + return TransportConfig.builder().dockerHost(URI.create("unix://" + getSocketPath().toString())).build(); + } + + @Override + protected boolean isApplicable() { + return SystemUtils.IS_OS_LINUX && getSocketPath() != null && Files.exists(getSocketPath()); + } + + @Override + public String getDescription() { + return "Rootless Docker accessed via Unix socket (" + getSocketPath() + ")"; + } + + @Override + protected int getPriority() { + return PRIORITY; + } + + private interface LibC extends Library { + LibC INSTANCE = Native.loadLibrary("c", LibC.class); + + int getuid(); + } +} diff --git a/core/src/main/java/org/testcontainers/dockerclient/TestcontainersHostPropertyClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/TestcontainersHostPropertyClientProviderStrategy.java new file mode 100644 index 00000000000..6e06367d5b6 --- /dev/null +++ b/core/src/main/java/org/testcontainers/dockerclient/TestcontainersHostPropertyClientProviderStrategy.java @@ -0,0 +1,70 @@ +package org.testcontainers.dockerclient; + +import com.github.dockerjava.core.DefaultDockerClientConfig; +import com.github.dockerjava.core.DockerClientConfig; +import org.testcontainers.utility.TestcontainersConfiguration; + +import java.util.Optional; + +/** + * Use tc.host in ~/.testcontainers.properties + * to try and locate a docker environment. + * + * @deprecated this class is used by the SPI and should not be used directly + */ +@Deprecated +public final class TestcontainersHostPropertyClientProviderStrategy extends DockerClientProviderStrategy { + + public static final int PRIORITY = EnvironmentAndSystemPropertyClientProviderStrategy.PRIORITY - 10; + + private DockerClientConfig dockerClientConfig; + + public TestcontainersHostPropertyClientProviderStrategy() { + this(DefaultDockerClientConfig.createDefaultConfigBuilder()); + } + + TestcontainersHostPropertyClientProviderStrategy(DefaultDockerClientConfig.Builder configBuilder) { + Optional tcHost = Optional.ofNullable( + TestcontainersConfiguration.getInstance().getUserProperty("tc.host", null) + ); + + if (tcHost.isPresent()) { + configBuilder.withDockerHost(tcHost.get()); + this.dockerClientConfig = configBuilder.build(); + } + } + + @Override + public String getDescription() { + return "Testcontainers Host with tc.host=" + this.dockerClientConfig.getDockerHost(); + } + + @Override + public TransportConfig getTransportConfig() throws InvalidConfigurationException { + return TransportConfig + .builder() + .dockerHost(dockerClientConfig.getDockerHost()) + .sslConfig(dockerClientConfig.getSSLConfig()) + .build(); + } + + @Override + protected boolean isApplicable() { + return this.dockerClientConfig != null; + } + + @Override + protected int getPriority() { + return PRIORITY; + } + + @Override + protected boolean isPersistable() { + return false; + } + + @Override + public boolean allowUserOverrides() { + return false; + } +} diff --git a/core/src/main/java/org/testcontainers/dockerclient/TransportConfig.java b/core/src/main/java/org/testcontainers/dockerclient/TransportConfig.java new file mode 100644 index 00000000000..cbd1278b179 --- /dev/null +++ b/core/src/main/java/org/testcontainers/dockerclient/TransportConfig.java @@ -0,0 +1,18 @@ +package org.testcontainers.dockerclient; + +import com.github.dockerjava.transport.SSLConfig; +import lombok.Builder; +import lombok.Value; +import org.jetbrains.annotations.Nullable; + +import java.net.URI; + +@Builder +@Value +public class TransportConfig { + + URI dockerHost; + + @Nullable + SSLConfig sslConfig; +} diff --git a/core/src/main/java/org/testcontainers/dockerclient/UnixSocketClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/UnixSocketClientProviderStrategy.java index e4aac02e6ed..c262bda1db5 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/UnixSocketClientProviderStrategy.java +++ b/core/src/main/java/org/testcontainers/dockerclient/UnixSocketClientProviderStrategy.java @@ -1,41 +1,30 @@ package org.testcontainers.dockerclient; -import com.github.dockerjava.core.DefaultDockerClientConfig; -import com.github.dockerjava.core.DockerClientConfig; -import org.jetbrains.annotations.NotNull; +import org.apache.commons.lang3.SystemUtils; import java.io.IOException; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -public class UnixSocketClientProviderStrategy extends DockerClientProviderStrategy { +/** + * + * @deprecated this class is used by the SPI and should not be used directly + */ +@Deprecated +public final class UnixSocketClientProviderStrategy extends DockerClientProviderStrategy { + protected static final String DOCKER_SOCK_PATH = "/var/run/docker.sock"; + private static final String SOCKET_LOCATION = "unix://" + DOCKER_SOCK_PATH; + private static final int SOCKET_FILE_MODE_MASK = 0xc000; - private static final String PING_TIMEOUT_DEFAULT = "10"; - private static final String PING_TIMEOUT_PROPERTY_NAME = "testcontainers.unixsocketprovider.timeout"; + public static final int PRIORITY = EnvironmentAndSystemPropertyClientProviderStrategy.PRIORITY - 20; @Override - public void test() - throws InvalidConfigurationException { - - if (!System.getProperty("os.name").toLowerCase().contains("linux")) { - throw new InvalidConfigurationException("this strategy is only applicable to Linux"); - } - - try { - config = tryConfiguration(SOCKET_LOCATION); - LOGGER.info("Accessing docker with local Unix socket"); - } catch (Exception | UnsatisfiedLinkError e) { - throw new InvalidConfigurationException("ping failed", e); - } - } - - @NotNull - protected DockerClientConfig tryConfiguration(String dockerHost) { - + public TransportConfig getTransportConfig() throws InvalidConfigurationException { Path dockerSocketFile = Paths.get(DOCKER_SOCK_PATH); Integer mode; try { @@ -45,19 +34,17 @@ protected DockerClientConfig tryConfiguration(String dockerHost) { } if ((mode & 0xc000) != SOCKET_FILE_MODE_MASK) { - throw new InvalidConfigurationException("Found docker unix domain socket but file mode was not as expected (expected: srwxr-xr-x). This problem is possibly due to occurrence of this issue in the past: https://github.com/docker/docker/issues/13121"); + throw new InvalidConfigurationException( + "Found docker unix domain socket but file mode was not as expected (expected: srwxr-xr-x). This problem is possibly due to occurrence of this issue in the past: https://github.com/docker/docker/issues/13121" + ); } - config = DefaultDockerClientConfig.createDefaultConfigBuilder() - .withDockerHost(dockerHost) - .withDockerTlsVerify(false) - .build(); - client = getClientForConfig(config); - - final int timeout = Integer.parseInt(System.getProperty(PING_TIMEOUT_PROPERTY_NAME, PING_TIMEOUT_DEFAULT)); - ping(client, timeout); + return TransportConfig.builder().dockerHost(URI.create(SOCKET_LOCATION)).build(); + } - return config; + @Override + protected boolean isApplicable() { + return SystemUtils.IS_OS_LINUX || SystemUtils.IS_OS_MAC; } @Override @@ -65,4 +52,8 @@ public String getDescription() { return "local Unix socket (" + SOCKET_LOCATION + ")"; } + @Override + protected int getPriority() { + return PRIORITY; + } } diff --git a/core/src/main/java/org/testcontainers/dockerclient/WindowsClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/WindowsClientProviderStrategy.java deleted file mode 100644 index 15cd35c48bf..00000000000 --- a/core/src/main/java/org/testcontainers/dockerclient/WindowsClientProviderStrategy.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.testcontainers.dockerclient; - -import com.github.dockerjava.core.DefaultDockerClientConfig; -import com.github.dockerjava.core.DockerClientConfig; -import org.jetbrains.annotations.NotNull; - -public class WindowsClientProviderStrategy extends DockerClientProviderStrategy { - - private static final int PING_TIMEOUT_DEFAULT = 5; - private static final String PING_TIMEOUT_PROPERTY_NAME = "testcontainers.windowsprovider.timeout"; - - @Override - public void test() throws InvalidConfigurationException { - config = tryConfiguration("tcp://localhost:2375"); - } - - @Override - public String getDescription() { - return "Docker for Windows (via TCP port 2375)"; - } - - @NotNull - protected DockerClientConfig tryConfiguration(String dockerHost) { - config = DefaultDockerClientConfig.createDefaultConfigBuilder() - .withDockerHost(dockerHost) - .withDockerTlsVerify(false) - .build(); - client = getClientForConfig(config); - - final int timeout = Integer.getInteger(PING_TIMEOUT_PROPERTY_NAME, PING_TIMEOUT_DEFAULT); - ping(client, timeout); - - return config; - } -} diff --git a/core/src/main/java/org/testcontainers/images/AbstractImagePullPolicy.java b/core/src/main/java/org/testcontainers/images/AbstractImagePullPolicy.java new file mode 100644 index 00000000000..1f9583ef4cf --- /dev/null +++ b/core/src/main/java/org/testcontainers/images/AbstractImagePullPolicy.java @@ -0,0 +1,48 @@ +package org.testcontainers.images; + +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.DockerLoggerFactory; + +@Slf4j +public abstract class AbstractImagePullPolicy implements ImagePullPolicy { + + private static final LocalImagesCache LOCAL_IMAGES_CACHE = LocalImagesCache.INSTANCE; + + @Override + public boolean shouldPull(DockerImageName imageName) { + Logger logger = DockerLoggerFactory.getLogger(imageName.asCanonicalNameString()); + + // Does our cache already know the image? + ImageData cachedImageData = LOCAL_IMAGES_CACHE.get(imageName); + if (cachedImageData != null) { + logger.trace("{} is already in image name cache", imageName); + } else { + logger.debug("{} is not in image name cache, updating...", imageName); + // Was not in cache, inspecting... + cachedImageData = LOCAL_IMAGES_CACHE.refreshCache(imageName).orElse(null); + + if (cachedImageData == null) { + log.debug("Not available locally, should pull image: {}", imageName); + return true; + } + } + + if (shouldPullCached(imageName, cachedImageData)) { + log.debug("Should pull locally available image: {}", imageName); + return true; + } else { + log.debug("Using locally available and not pulling image: {}", imageName); + return false; + } + } + + /** + * Implement this method to decide whether a locally available image should be pulled + * (e.g. to always pull images, or to pull them after some duration of time) + * + * @return {@code true} to update the locally available image, {@code false} to use local instead + */ + protected abstract boolean shouldPullCached(DockerImageName imageName, ImageData localImageData); +} diff --git a/core/src/main/java/org/testcontainers/images/AgeBasedPullPolicy.java b/core/src/main/java/org/testcontainers/images/AgeBasedPullPolicy.java new file mode 100644 index 00000000000..39028d6c52b --- /dev/null +++ b/core/src/main/java/org/testcontainers/images/AgeBasedPullPolicy.java @@ -0,0 +1,28 @@ +package org.testcontainers.images; + +import lombok.Value; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.time.Instant; + +/** + * An ImagePullPolicy which pulls the image from a remote repository only if its created date is older than maxAge + */ +@Slf4j +@Value +class AgeBasedPullPolicy extends AbstractImagePullPolicy { + + Duration maxAge; + + @Override + protected boolean shouldPullCached(DockerImageName imageName, ImageData localImageData) { + Duration imageAge = Duration.between(localImageData.getCreatedAt(), Instant.now()); + boolean result = imageAge.compareTo(maxAge) > 0; + if (result) { + log.trace("Should pull image: {}", imageName); + } + return result; + } +} diff --git a/core/src/main/java/org/testcontainers/images/AlwaysPullPolicy.java b/core/src/main/java/org/testcontainers/images/AlwaysPullPolicy.java new file mode 100644 index 00000000000..92f94f67124 --- /dev/null +++ b/core/src/main/java/org/testcontainers/images/AlwaysPullPolicy.java @@ -0,0 +1,20 @@ +package org.testcontainers.images; + +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.utility.DockerImageName; + +/*** + * An ImagePullPolicy which pulls the image even if it exists locally. + * Useful for obtaining the latest version of an image with a static tag, i.e. 'latest' + */ +@Slf4j +@ToString +class AlwaysPullPolicy implements ImagePullPolicy { + + @Override + public boolean shouldPull(DockerImageName imageName) { + log.trace("Unconditionally pulling an image: {}", imageName); + return true; + } +} diff --git a/core/src/main/java/org/testcontainers/images/DefaultPullPolicy.java b/core/src/main/java/org/testcontainers/images/DefaultPullPolicy.java new file mode 100644 index 00000000000..40984b2940e --- /dev/null +++ b/core/src/main/java/org/testcontainers/images/DefaultPullPolicy.java @@ -0,0 +1,18 @@ +package org.testcontainers.images; + +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.utility.DockerImageName; + +/** + * The default imagePullPolicy, which pulls the image from a remote repository only if it does not exist locally + */ +@Slf4j +@ToString +class DefaultPullPolicy extends AbstractImagePullPolicy { + + @Override + protected boolean shouldPullCached(DockerImageName imageName, ImageData localImageData) { + return false; + } +} diff --git a/core/src/main/java/org/testcontainers/images/ImageData.java b/core/src/main/java/org/testcontainers/images/ImageData.java new file mode 100644 index 00000000000..5b9f8227924 --- /dev/null +++ b/core/src/main/java/org/testcontainers/images/ImageData.java @@ -0,0 +1,32 @@ +package org.testcontainers.images; + +import com.github.dockerjava.api.command.InspectImageResponse; +import com.github.dockerjava.api.model.Image; +import lombok.Builder; +import lombok.NonNull; +import lombok.Value; + +import java.time.Instant; +import java.time.ZonedDateTime; + +@Value +@Builder +public class ImageData { + + @NonNull + Instant createdAt; + + static ImageData from(InspectImageResponse inspectImageResponse) { + final String created = inspectImageResponse.getCreated(); + final Instant createdInstant = ((created == null) || created.isEmpty()) + ? Instant.EPOCH + : ZonedDateTime.parse(created).toInstant(); + return ImageData.builder().createdAt(createdInstant).build(); + } + + static ImageData from(Image image) { + final Long created = image.getCreated(); + final Instant createdInstant = (created == null) ? Instant.EPOCH : Instant.ofEpochSecond(created); + return ImageData.builder().createdAt(createdInstant).build(); + } +} diff --git a/core/src/main/java/org/testcontainers/images/ImagePullPolicy.java b/core/src/main/java/org/testcontainers/images/ImagePullPolicy.java new file mode 100644 index 00000000000..a090c3f7b9c --- /dev/null +++ b/core/src/main/java/org/testcontainers/images/ImagePullPolicy.java @@ -0,0 +1,7 @@ +package org.testcontainers.images; + +import org.testcontainers.utility.DockerImageName; + +public interface ImagePullPolicy { + boolean shouldPull(DockerImageName imageName); +} diff --git a/core/src/main/java/org/testcontainers/images/LocalImagesCache.java b/core/src/main/java/org/testcontainers/images/LocalImagesCache.java new file mode 100644 index 00000000000..c98339e9f4c --- /dev/null +++ b/core/src/main/java/org/testcontainers/images/LocalImagesCache.java @@ -0,0 +1,91 @@ +package org.testcontainers.images; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.InspectImageResponse; +import com.github.dockerjava.api.exception.NotFoundException; +import com.github.dockerjava.api.model.Image; +import com.google.common.annotations.VisibleForTesting; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.utility.DockerImageName; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Slf4j +enum LocalImagesCache { + INSTANCE; + + @VisibleForTesting + final AtomicBoolean initialized = new AtomicBoolean(false); + + @VisibleForTesting + final Map cache = new ConcurrentHashMap<>(); + + public ImageData get(DockerImageName imageName) { + maybeInitCache(DockerClientFactory.instance().client()); + return cache.get(imageName); + } + + public Optional refreshCache(DockerImageName imageName) { + DockerClient dockerClient = DockerClientFactory.instance().client(); + if (!maybeInitCache(dockerClient)) { + // Cache may be stale, trying inspectImageCmd... + + InspectImageResponse response = null; + try { + response = dockerClient.inspectImageCmd(imageName.asCanonicalNameString()).exec(); + } catch (NotFoundException e) { + log.trace("Image {} not found", imageName, e); + } + if (response != null) { + ImageData imageData = ImageData.from(response); + cache.put(imageName, imageData); + return Optional.of(imageData); + } else { + cache.remove(imageName); + return Optional.empty(); + } + } + + return Optional.ofNullable(cache.get(imageName)); + } + + private synchronized boolean maybeInitCache(DockerClient dockerClient) { + if (!initialized.compareAndSet(false, true)) { + return false; + } + + if (Boolean.parseBoolean(System.getProperty("useFilter"))) { + return false; + } + + populateFromList(dockerClient.listImagesCmd().exec()); + + return true; + } + + private void populateFromList(List images) { + for (Image image : images) { + String[] repoTags = image.getRepoTags(); + if (repoTags == null) { + log.debug("repoTags is null, skipping image: {}", image); + continue; + } + + cache.putAll( + Stream + .of(repoTags) + // Protection against some edge case where local image repository tags end up with duplicates + // making toMap crash at merge time. + .distinct() + .collect(Collectors.toMap(DockerImageName::new, it -> ImageData.from(image))) + ); + } + } +} diff --git a/core/src/main/java/org/testcontainers/images/LoggedPullImageResultCallback.java b/core/src/main/java/org/testcontainers/images/LoggedPullImageResultCallback.java new file mode 100644 index 00000000000..73af8a05031 --- /dev/null +++ b/core/src/main/java/org/testcontainers/images/LoggedPullImageResultCallback.java @@ -0,0 +1,133 @@ +package org.testcontainers.images; + +import com.github.dockerjava.api.command.PullImageResultCallback; +import com.github.dockerjava.api.model.PullResponseItem; +import org.apache.commons.io.FileUtils; +import org.slf4j.Logger; + +import java.io.Closeable; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * {@link PullImageResultCallback} with improved logging of pull progress. + */ +class LoggedPullImageResultCallback extends PullImageResultCallback { + + private final Logger logger; + + private final Set allLayers = new HashSet<>(); + + private final Set downloadedLayers = new HashSet<>(); + + private final Set pulledLayers = new HashSet<>(); + + private final Map totalSizes = new HashMap<>(); + + private final Map currentSizes = new HashMap<>(); + + private boolean completed; + + private Instant start; + + LoggedPullImageResultCallback(final Logger logger) { + this.logger = logger; + } + + @Override + public void onStart(final Closeable stream) { + super.onStart(stream); + start = Instant.now(); + + logger.info("Starting to pull image"); + } + + @Override + public void onNext(final PullResponseItem item) { + super.onNext(item); + + final String statusLowercase = item.getStatus() != null ? item.getStatus().toLowerCase() : ""; + final String id = item.getId(); + + if (item.getProgressDetail() != null) { + allLayers.add(id); + } + + if (statusLowercase.equalsIgnoreCase("download complete")) { + downloadedLayers.add(id); + } + + if (statusLowercase.equalsIgnoreCase("pull complete")) { + pulledLayers.add(id); + } + + if (item.getProgressDetail() != null) { + Long total = item.getProgressDetail().getTotal(); + Long current = item.getProgressDetail().getCurrent(); + + if (total != null && total > totalSizes.getOrDefault(id, 0L)) { + totalSizes.put(id, total); + } + if (current != null && current > currentSizes.getOrDefault(id, 0L)) { + currentSizes.put(id, current); + } + } + + if (statusLowercase.startsWith("pulling from") || statusLowercase.contains("complete")) { + long totalSize = totalLayerSize(); + long currentSize = downloadedLayerSize(); + + int pendingCount = allLayers.size() - downloadedLayers.size(); + String friendlyTotalSize; + if (pendingCount > 0) { + friendlyTotalSize = "? MB"; + } else { + friendlyTotalSize = FileUtils.byteCountToDisplaySize(totalSize); + } + + logger.info( + "Pulling image layers: {} pending, {} downloaded, {} extracted, ({}/{})", + String.format("%2d", pendingCount), + String.format("%2d", downloadedLayers.size()), + String.format("%2d", pulledLayers.size()), + FileUtils.byteCountToDisplaySize(currentSize), + friendlyTotalSize + ); + } + + if (statusLowercase.contains("complete")) { + completed = true; + } + } + + @Override + public void onComplete() { + super.onComplete(); + + final long downloadedLayerSize = downloadedLayerSize(); + final long duration = Duration.between(start, Instant.now()).getSeconds(); + + if (completed) { + logger.info( + "Pull complete. {} layers, pulled in {}s (downloaded {} at {}/s)", + allLayers.size(), + duration, + FileUtils.byteCountToDisplaySize(downloadedLayerSize), + FileUtils.byteCountToDisplaySize(downloadedLayerSize / duration) + ); + } + } + + private long downloadedLayerSize() { + return currentSizes.values().stream().filter(Objects::nonNull).mapToLong(it -> it).sum(); + } + + private long totalLayerSize() { + return totalSizes.values().stream().filter(Objects::nonNull).mapToLong(it -> it).sum(); + } +} diff --git a/core/src/main/java/org/testcontainers/images/ParsedDockerfile.java b/core/src/main/java/org/testcontainers/images/ParsedDockerfile.java new file mode 100644 index 00000000000..8c4d7866ff4 --- /dev/null +++ b/core/src/main/java/org/testcontainers/images/ParsedDockerfile.java @@ -0,0 +1,72 @@ +package org.testcontainers.images; + +import com.google.common.annotations.VisibleForTesting; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Representation of a Dockerfile, with partial parsing for extraction of a minimal set of data. + */ +@Slf4j +public class ParsedDockerfile { + + private static final Pattern FROM_LINE_PATTERN = Pattern.compile( + "FROM (?--[^\\s]+\\s)*(?[^\\s]+).*", + Pattern.CASE_INSENSITIVE + ); + + private final Path dockerFilePath; + + @Getter + private final Set dependencyImageNames; + + public ParsedDockerfile(Path dockerFilePath) { + this.dockerFilePath = dockerFilePath; + this.dependencyImageNames = parse(read()); + } + + @VisibleForTesting + ParsedDockerfile(List lines) { + this.dockerFilePath = Paths.get("dummy.Dockerfile"); + this.dependencyImageNames = parse(lines); + } + + private List read() { + if (!Files.exists(dockerFilePath)) { + log.warn("Tried to parse Dockerfile at path {} but none was found", dockerFilePath); + return Collections.emptyList(); + } + + try { + return Files.readAllLines(dockerFilePath); + } catch (IOException e) { + log.warn("Unable to read Dockerfile at path {}", dockerFilePath, e); + return Collections.emptyList(); + } + } + + private Set parse(List lines) { + Set imageNames = lines + .stream() + .map(FROM_LINE_PATTERN::matcher) + .filter(Matcher::matches) + .map(matcher -> matcher.group("image")) + .collect(Collectors.toSet()); + + if (!imageNames.isEmpty()) { + log.debug("Found dependency images in Dockerfile {}: {}", dockerFilePath, imageNames); + } + return imageNames; + } +} diff --git a/core/src/main/java/org/testcontainers/images/PullPolicy.java b/core/src/main/java/org/testcontainers/images/PullPolicy.java new file mode 100644 index 00000000000..8c3a067cc15 --- /dev/null +++ b/core/src/main/java/org/testcontainers/images/PullPolicy.java @@ -0,0 +1,79 @@ +package org.testcontainers.images; + +import com.google.common.annotations.VisibleForTesting; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.utility.TestcontainersConfiguration; + +import java.time.Duration; + +/** + * Convenience class with logic for building common {@link ImagePullPolicy} instances. + * + */ +@Slf4j +@UtilityClass +public class PullPolicy { + + @VisibleForTesting + static ImagePullPolicy instance; + + @VisibleForTesting + static ImagePullPolicy defaultImplementation = new DefaultPullPolicy(); + + /** + * Convenience method for returning the {@link DefaultPullPolicy} default image pull policy + * @return {@link ImagePullPolicy} + */ + public static synchronized ImagePullPolicy defaultPolicy() { + if (instance != null) { + return instance; + } + + String imagePullPolicyClassName = TestcontainersConfiguration.getInstance().getImagePullPolicy(); + if (imagePullPolicyClassName != null) { + log.debug("Attempting to instantiate an ImagePullPolicy with class: {}", imagePullPolicyClassName); + ImagePullPolicy configuredInstance; + try { + configuredInstance = + (ImagePullPolicy) Thread + .currentThread() + .getContextClassLoader() + .loadClass(imagePullPolicyClassName) + .getDeclaredConstructor() + .newInstance(); + } catch (Exception e) { + throw new IllegalArgumentException( + "Configured ImagePullPolicy could not be loaded: " + imagePullPolicyClassName, + e + ); + } + + log.info("Found configured Image Pull Policy: {}", configuredInstance.getClass()); + + instance = configuredInstance; + } else { + instance = defaultImplementation; + } + + log.info("Image pull policy will be performed by: {}", instance); + + return instance; + } + + /** + * Convenience method for returning the {@link AlwaysPullPolicy} alwaysPull image pull policy + * @return {@link ImagePullPolicy} + */ + public static ImagePullPolicy alwaysPull() { + return new AlwaysPullPolicy(); + } + + /** + * Convenience method for returning an {@link AgeBasedPullPolicy} Age based image pull policy, + * @return {@link ImagePullPolicy} + */ + public static ImagePullPolicy ageBased(Duration maxAge) { + return new AgeBasedPullPolicy(maxAge); + } +} diff --git a/core/src/main/java/org/testcontainers/images/RemoteDockerImage.java b/core/src/main/java/org/testcontainers/images/RemoteDockerImage.java index 01ba3ad6894..fdd7279f86b 100644 --- a/core/src/main/java/org/testcontainers/images/RemoteDockerImage.java +++ b/core/src/main/java/org/testcontainers/images/RemoteDockerImage.java @@ -1,100 +1,191 @@ package org.testcontainers.images; import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.PullImageCmd; import com.github.dockerjava.api.exception.DockerClientException; -import com.github.dockerjava.api.model.Image; -import com.github.dockerjava.core.command.PullImageResultCallback; +import com.github.dockerjava.api.exception.InternalServerErrorException; +import com.github.dockerjava.api.exception.NotFoundException; +import com.google.common.util.concurrent.Futures; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.ToString; +import lombok.With; +import org.awaitility.Awaitility; +import org.awaitility.pollinterval.IterativePollInterval; +import org.awaitility.pollinterval.PollInterval; import org.slf4j.Logger; -import org.slf4j.profiler.Profiler; import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.ContainerFetchException; import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.DockerLoggerFactory; +import org.testcontainers.utility.ImageNameSubstitutor; import org.testcontainers.utility.LazyFuture; +import org.testcontainers.utility.TestcontainersConfiguration; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; + +@ToString +@AllArgsConstructor(access = AccessLevel.PACKAGE) +public class RemoteDockerImage extends LazyFuture { -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; + private static final Duration PULL_RETRY_TIME_LIMIT = Duration.ofSeconds( + TestcontainersConfiguration.getInstance().getImagePullTimeout() + ); -public class RemoteDockerImage extends LazyFuture { + @ToString.Exclude + private Future imageNameFuture; - private static final Set AVAILABLE_IMAGE_NAME_CACHE = new HashSet<>(); + @With + ImagePullPolicy imagePullPolicy = PullPolicy.defaultPolicy(); - private final String dockerImageName; + @With + private ImageNameSubstitutor imageNameSubstitutor = ImageNameSubstitutor.instance(); + @ToString.Exclude + private DockerClient dockerClient = DockerClientFactory.lazyClient(); + + public RemoteDockerImage(DockerImageName dockerImageName) { + this.imageNameFuture = CompletableFuture.completedFuture(dockerImageName); + } + + @Deprecated public RemoteDockerImage(String dockerImageName) { - DockerImageName.validate(dockerImageName); - this.dockerImageName = dockerImageName; + this(DockerImageName.parse(dockerImageName)); } + @Deprecated public RemoteDockerImage(@NonNull String repository, @NonNull String tag) { - this.dockerImageName = repository + ":" + tag; + this(DockerImageName.parse(repository).withTag(tag)); + } + + public RemoteDockerImage(@NonNull Future imageFuture) { + this.imageNameFuture = Futures.lazyTransform(imageFuture, DockerImageName::new); } @Override + @SneakyThrows({ InterruptedException.class, ExecutionException.class }) protected final String resolve() { - Profiler profiler = new Profiler("Rule creation - prefetch image"); - Logger logger = DockerLoggerFactory.getLogger(dockerImageName); - profiler.setLogger(logger); - - Profiler nested = profiler.startNested("Obtaining client"); - DockerClient dockerClient = DockerClientFactory.instance().client(); + final DockerImageName imageName = getImageName(); + final Logger logger = DockerLoggerFactory.getLogger(imageName.toString()); try { - nested.stop(); - - profiler.start("Check local images"); - - int attempts = 0; - while (true) { - // Does our cache already know the image? - if (AVAILABLE_IMAGE_NAME_CACHE.contains(dockerImageName)) { - logger.trace("{} is already in image name cache", dockerImageName); - break; - } - - // Update the cache - List updatedImages = dockerClient.listImagesCmd().exec(); - for (Image image : updatedImages) { - if (image.getRepoTags() != null) { - Collections.addAll(AVAILABLE_IMAGE_NAME_CACHE, image.getRepoTags()); - } - } - - // And now? - if (AVAILABLE_IMAGE_NAME_CACHE.contains(dockerImageName)) { - logger.trace("{} is in image name cache following listing of images", dockerImageName); - break; - } - - // Log only on first attempt - if (attempts == 0) { - logger.info("Pulling docker image: {}. Please be patient; this may take some time but only needs to be done once.", dockerImageName); - profiler.start("Pull image"); - } - - if (attempts++ >= 3) { - logger.error("Retry limit reached while trying to pull image: " + dockerImageName + ". Please check output of `docker pull " + dockerImageName + "`"); - throw new ContainerFetchException("Retry limit reached while trying to pull image: " + dockerImageName); - } - - // The image is not available locally - pull it - try { - dockerClient.pullImageCmd(dockerImageName).exec(new PullImageResultCallback()).awaitCompletion(); - } catch (InterruptedException e) { - throw new ContainerFetchException("Failed to fetch container image for " + dockerImageName, e); - } - - // Do not break here, but step into the next iteration, where it will be verified with listImagesCmd(). - // see https://github.com/docker/docker/issues/10708 + if (!imagePullPolicy.shouldPull(imageName)) { + return imageName.asCanonicalNameString(); + } + + // The image is not available locally - pull it + logger.info( + "Pulling docker image: {}. Please be patient; this may take some time but only needs to be done once.", + imageName + ); + + final Instant startedAt = Instant.now(); + final Instant lastRetryAllowed = Instant.now().plus(PULL_RETRY_TIME_LIMIT); + final AtomicReference lastFailure = new AtomicReference<>(); + String pullTag = imageName.getDigest() != null ? imageName.getDigest() : imageName.getVersionPart(); + final PullImageCmd pullImageCmd = dockerClient + .pullImageCmd(imageName.getUnversionedPart()) + .withTag(pullTag); + final AtomicReference dockerImageName = new AtomicReference<>(); + + // The following poll interval in ms: 50, 100, 200, 400, 800.... + // Results in ~70 requests in over 2 minutes + final PollInterval interval = IterativePollInterval + .iterative(duration -> duration.multipliedBy(2)) + .startDuration(Duration.ofMillis(50)); + + Awaitility + .await() + .pollInSameThread() + .pollDelay(Duration.ZERO) // start checking immediately + .atMost(PULL_RETRY_TIME_LIMIT) + .pollInterval(interval) + .until( + tryImagePullCommand(pullImageCmd, logger, dockerImageName, imageName, lastFailure, lastRetryAllowed) + ); + + if (dockerImageName.get() == null) { + final Exception lastException = lastFailure.get(); + logger.error( + "Failed to pull image: {}. Please check output of `docker pull {}`", + imageName, + imageName, + lastException + ); + throw new ContainerFetchException("Failed to pull image: " + imageName, lastException); } - return dockerImageName; + logger.info("Image {} pull took {}", dockerImageName.get(), Duration.between(startedAt, Instant.now())); + LocalImagesCache.INSTANCE.refreshCache(imageName); + return dockerImageName.get(); } catch (DockerClientException e) { - throw new ContainerFetchException("Failed to get Docker client for " + dockerImageName, e); - } finally { - profiler.stop().log(); + throw new ContainerFetchException("Failed to get Docker client for " + imageName, e); + } + } + + private Callable tryImagePullCommand( + PullImageCmd pullImageCmd, + Logger logger, + AtomicReference dockerImageName, + DockerImageName imageName, + AtomicReference lastFailure, + Instant lastRetryAllowed + ) { + return () -> { + try { + pullImage(pullImageCmd, logger); + dockerImageName.set(imageName.asCanonicalNameString()); + return true; + } catch (InterruptedException | InternalServerErrorException e) { + // these classes of exception often relate to timeout/connection errors so should be retried + lastFailure.set(e); + logger.warn( + "Retrying pull for image: {} ({}s remaining)", + imageName, + Duration.between(Instant.now(), lastRetryAllowed).getSeconds() + ); + return false; + } + }; + } + + private TimeLimitedLoggedPullImageResultCallback pullImage(PullImageCmd pullImageCmd, Logger logger) + throws InterruptedException { + try { + return pullImageCmd.exec(new TimeLimitedLoggedPullImageResultCallback(logger)).awaitCompletion(); + } catch (DockerClientException | NotFoundException e) { + // Try to fallback to x86 + return pullImageCmd + .withPlatform("linux/amd64") + .exec(new TimeLimitedLoggedPullImageResultCallback(logger)) + .awaitCompletion(); + } + } + + private DockerImageName getImageName() throws InterruptedException, ExecutionException { + final DockerImageName specifiedImageName = imageNameFuture.get(); + + // Allow the image name to be substituted + return imageNameSubstitutor.apply(specifiedImageName); + } + + @ToString.Include(name = "imageName", rank = 1) + private String imageNameToString() { + if (!imageNameFuture.isDone()) { + return ""; + } + + try { + return getImageName().asCanonicalNameString(); + } catch (InterruptedException | ExecutionException e) { + return e.getMessage(); } } } diff --git a/core/src/main/java/org/testcontainers/images/TimeLimitedLoggedPullImageResultCallback.java b/core/src/main/java/org/testcontainers/images/TimeLimitedLoggedPullImageResultCallback.java new file mode 100644 index 00000000000..88b8804dd8a --- /dev/null +++ b/core/src/main/java/org/testcontainers/images/TimeLimitedLoggedPullImageResultCallback.java @@ -0,0 +1,127 @@ +package org.testcontainers.images; + +import com.github.dockerjava.api.command.PullImageResultCallback; +import com.github.dockerjava.api.model.PullResponseItem; +import org.slf4j.Logger; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.utility.TestcontainersConfiguration; + +import java.io.Closeable; +import java.io.IOException; +import java.time.Duration; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * {@link PullImageResultCallback} with improved logging of pull progress and a 'watchdog' which will abort the pull + * if progress is not being made, to prevent a hanging test + */ +public class TimeLimitedLoggedPullImageResultCallback extends LoggedPullImageResultCallback { + + private static final AtomicInteger THREAD_ID = new AtomicInteger(0); + + private static final ScheduledExecutorService PROGRESS_WATCHDOG_EXECUTOR = Executors.newScheduledThreadPool( + 0, + runnable -> { + Thread t = new Thread(DockerClientFactory.TESTCONTAINERS_THREAD_GROUP, runnable); + t.setDaemon(true); + t.setName("testcontainers-pull-watchdog-" + THREAD_ID.incrementAndGet()); + return t; + } + ); + + private static final Duration PULL_PAUSE_TOLERANCE = Duration.ofSeconds( + TestcontainersConfiguration.getInstance().getImagePullPauseTimeout() + ); + + private final Logger logger; + + // A future which, if it ever fires, will kill the pull + private ScheduledFuture nextCheckForProgress; + + // All threads that are 'awaiting' this pull + private final Set waitingThreads = new HashSet<>(); + + public TimeLimitedLoggedPullImageResultCallback(Logger logger) { + super(logger); + this.logger = logger; + } + + @Override + public TimeLimitedLoggedPullImageResultCallback awaitCompletion() throws InterruptedException { + waitingThreads.add(Thread.currentThread()); + super.awaitCompletion(); + return this; + } + + @Override + public boolean awaitCompletion(long timeout, TimeUnit timeUnit) throws InterruptedException { + waitingThreads.add(Thread.currentThread()); + return super.awaitCompletion(timeout, timeUnit); + } + + @Override + public void onNext(PullResponseItem item) { + if (item.getProgressDetail() != null) { + resetProgressWatchdog(false); + } + super.onNext(item); + } + + @Override + public void onStart(Closeable stream) { + resetProgressWatchdog(false); + super.onStart(stream); + } + + @Override + public void onError(Throwable throwable) { + resetProgressWatchdog(true); + super.onError(throwable); + } + + @Override + public void onComplete() { + resetProgressWatchdog(true); + super.onComplete(); + } + + /* + * This method schedules a future task which will interrupt the waiting waiting threads if ever fired. + * Every time this method is called (from onStart or onNext), the task is cancelled and recreated 30s in the future, + * ensuring that it will only fire if the method stops being called regularly (e.g. if the pull has hung). + */ + private void resetProgressWatchdog(boolean isFinished) { + if (nextCheckForProgress != null && !nextCheckForProgress.isCancelled()) { + nextCheckForProgress.cancel(false); + } + if (!isFinished) { + nextCheckForProgress = + PROGRESS_WATCHDOG_EXECUTOR.schedule( + this::abortPull, + PULL_PAUSE_TOLERANCE.getSeconds(), + TimeUnit.SECONDS + ); + } + } + + private void abortPull() { + logger.error( + "Docker image pull has not made progress in {}s - aborting pull", + PULL_PAUSE_TOLERANCE.getSeconds() + ); + // Interrupt any threads that are waiting, before closing streams, because the stream can take + // an indeterminate amount of time to close + waitingThreads.forEach(Thread::interrupt); + try { + close(); + } catch (IOException ignored) { + // no action + } + } +} diff --git a/core/src/main/java/org/testcontainers/images/builder/ImageFromDockerfile.java b/core/src/main/java/org/testcontainers/images/builder/ImageFromDockerfile.java index e51e6d003af..ced612bfefa 100644 --- a/core/src/main/java/org/testcontainers/images/builder/ImageFromDockerfile.java +++ b/core/src/main/java/org/testcontainers/images/builder/ImageFromDockerfile.java @@ -2,69 +2,75 @@ import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.command.BuildImageCmd; -import com.github.dockerjava.api.exception.DockerClientException; +import com.github.dockerjava.api.command.BuildImageResultCallback; import com.github.dockerjava.api.model.BuildResponseItem; -import com.github.dockerjava.core.command.BuildImageResultCallback; -import com.google.common.collect.Sets; import lombok.Cleanup; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; -import org.apache.commons.lang.StringUtils; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; -import org.slf4j.profiler.Profiler; import org.testcontainers.DockerClientFactory; -import org.testcontainers.images.builder.traits.*; +import org.testcontainers.images.ParsedDockerfile; +import org.testcontainers.images.RemoteDockerImage; +import org.testcontainers.images.builder.traits.BuildContextBuilderTrait; +import org.testcontainers.images.builder.traits.ClasspathTrait; +import org.testcontainers.images.builder.traits.DockerfileTrait; +import org.testcontainers.images.builder.traits.FilesTrait; +import org.testcontainers.images.builder.traits.StringsTrait; import org.testcontainers.utility.Base58; +import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.DockerLoggerFactory; +import org.testcontainers.utility.ImageNameSubstitutor; import org.testcontainers.utility.LazyFuture; +import org.testcontainers.utility.ResourceReaper; import java.io.IOException; import java.io.PipedInputStream; import java.io.PipedOutputStream; +import java.nio.file.Path; +import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Optional; import java.util.Set; +import java.util.function.Consumer; +import java.util.regex.Matcher; import java.util.zip.GZIPOutputStream; @Slf4j @Getter -public class ImageFromDockerfile extends LazyFuture implements +public class ImageFromDockerfile + extends LazyFuture + implements BuildContextBuilderTrait, ClasspathTrait, FilesTrait, StringsTrait, DockerfileTrait { - private static final Set imagesToDelete = Sets.newConcurrentHashSet(); - - static { - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - DockerClient dockerClientForCleaning = DockerClientFactory.instance().client(); - try { - for (String dockerImageName : imagesToDelete) { - log.info("Removing image tagged {}", dockerImageName); - try { - dockerClientForCleaning.removeImageCmd(dockerImageName).withForce(true).exec(); - } catch (Throwable e) { - log.warn("Unable to delete image " + dockerImageName, e); - } - } - } catch (DockerClientException e) { - throw new RuntimeException(e); - } - })); - } - private final String dockerImageName; private boolean deleteOnExit = true; private final Map transferables = new HashMap<>(); + private final Map buildArgs = new HashMap<>(); + + private Optional dockerFilePath = Optional.empty(); + + private Optional dockerfile = Optional.empty(); + + private Optional target = Optional.empty(); + + private final Set> buildImageCmdModifiers = new LinkedHashSet<>(); + + private Set dependencyImageNames = Collections.emptySet(); + public ImageFromDockerfile() { - this("testcontainers/" + Base58.randomString(16).toLowerCase()); + this("localhost/testcontainers/" + Base58.randomString(16).toLowerCase()); } public ImageFromDockerfile(String dockerImageName) { @@ -91,15 +97,10 @@ public ImageFromDockerfile withFileFromTransferable(String path, Transferable tr protected final String resolve() { Logger logger = DockerLoggerFactory.getLogger(dockerImageName); - Profiler profiler = new Profiler("Rule creation - build image"); - profiler.setLogger(logger); - + //noinspection resource DockerClient dockerClient = DockerClientFactory.instance().client(); - try { - if (deleteOnExit) { - imagesToDelete.add(dockerImageName); - } + try { BuildImageResultCallback resultCallback = new BuildImageResultCallback() { @Override public void onNext(BuildResponseItem item) { @@ -108,52 +109,175 @@ public void onNext(BuildResponseItem item) { if (item.isErrorIndicated()) { logger.error(item.getErrorDetail().getMessage()); } else { - logger.debug(StringUtils.chomp(item.getStream(), "\n")); + logger.debug(StringUtils.removeEnd(item.getStream(), "\n")); } } }; - // We have to use pipes to avoid high memory consumption since users might want to build really big images - @Cleanup PipedInputStream in = new PipedInputStream(); - @Cleanup PipedOutputStream out = new PipedOutputStream(in); + // We have to use pipes to avoid high memory consumption since users might want to build huge images + @Cleanup + PipedInputStream in = new PipedInputStream(); + @Cleanup + PipedOutputStream out = new PipedOutputStream(in); - profiler.start("Configure image"); BuildImageCmd buildImageCmd = dockerClient.buildImageCmd(in); configure(buildImageCmd); + Map labels = new HashMap<>(); + if (buildImageCmd.getLabels() != null) { + labels.putAll(buildImageCmd.getLabels()); + } + + labels.putAll(DockerClientFactory.DEFAULT_LABELS); + if (deleteOnExit) { + //noinspection deprecation + labels.putAll(ResourceReaper.instance().getLabels()); + } + buildImageCmd.withLabels(labels); + + prePullDependencyImages(dependencyImageNames); - profiler.start("Build image"); BuildImageResultCallback exec = buildImageCmd.exec(resultCallback); - // To build an image, we have to send the context to Docker in TAR archive format - profiler.start("Send context as TAR"); + long bytesToDockerDaemon = 0; + // To build an image, we have to send the context to Docker in TAR archive format try (TarArchiveOutputStream tarArchive = new TarArchiveOutputStream(new GZIPOutputStream(out))) { + tarArchive.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); + tarArchive.setBigNumberMode(TarArchiveOutputStream.BIGNUMBER_POSIX); + for (Map.Entry entry : transferables.entrySet()) { - TarArchiveEntry tarEntry = new TarArchiveEntry(entry.getKey()); Transferable transferable = entry.getValue(); - tarEntry.setSize(transferable.getSize()); - tarEntry.setMode(transferable.getFileMode()); - - tarArchive.putArchiveEntry(tarEntry); - transferable.transferTo(tarArchive); - tarArchive.closeArchiveEntry(); - + final String destination = entry.getKey(); + transferable.transferTo(tarArchive, destination); + bytesToDockerDaemon += transferable.getSize(); } tarArchive.finish(); } - profiler.start("Wait for an image id"); + log.info("Transferred {} to Docker daemon", FileUtils.byteCountToDisplaySize(bytesToDockerDaemon)); + if (bytesToDockerDaemon > FileUtils.ONE_MB * 50) { + log.warn( // warn if >50MB sent to docker daemon + "A large amount of data was sent to the Docker daemon ({}). Consider using a .dockerignore file for better performance.", + FileUtils.byteCountToDisplaySize(bytesToDockerDaemon) + ); + } + exec.awaitImageId(); return dockerImageName; - } catch(IOException e) { + } catch (IOException e) { throw new RuntimeException("Can't close DockerClient", e); - } finally { - profiler.stop().log(); } } protected void configure(BuildImageCmd buildImageCmd) { - buildImageCmd.withTag(this.getDockerImageName()); + buildImageCmd.withTags(Collections.singleton(getDockerImageName())); + this.dockerFilePath.ifPresent(buildImageCmd::withDockerfilePath); + this.dockerfile.ifPresent(p -> { + buildImageCmd.withDockerfile(p.toFile()); + dependencyImageNames = new ParsedDockerfile(p).getDependencyImageNames(); + + if (dependencyImageNames.size() > 0) { + // if we'll be pre-pulling images, disable the built-in pull as it is not necessary and will fail for + // authenticated registries + buildImageCmd.withPull(false); + } + }); + + this.buildArgs.forEach(buildImageCmd::withBuildArg); + this.target.ifPresent(buildImageCmd::withTarget); + this.buildImageCmdModifiers.forEach(hook -> hook.accept(buildImageCmd)); + } + + private void prePullDependencyImages(Set imagesToPull) { + imagesToPull.forEach(imageName -> { + String resolvedImageName = applyBuildArgsToImageName(imageName); + try { + log.info( + "Pre-emptively checking local images for '{}', referenced via a Dockerfile. If not available, it will be pulled.", + resolvedImageName + ); + new RemoteDockerImage(DockerImageName.parse(resolvedImageName)) + .withImageNameSubstitutor(ImageNameSubstitutor.noop()) + .get(); + } catch (Exception e) { + log.warn( + "Unable to pre-fetch an image ({}) depended upon by Dockerfile - image build will continue but may fail. Exception message was: {}", + resolvedImageName, + e.getMessage() + ); + } + }); + } + + /** + * See {@code filterForEnvironmentVars()} in {@link com.github.dockerjava.core.dockerfile.DockerfileStatement}. + */ + private String applyBuildArgsToImageName(String imageName) { + for (Map.Entry entry : buildArgs.entrySet()) { + String value = Matcher.quoteReplacement(entry.getValue()); + // handle: $VARIABLE case + imageName = imageName.replace("$" + entry.getKey(), value); + // handle ${VARIABLE} case + imageName = imageName.replace("${" + entry.getKey() + "}", value); + } + return imageName; + } + + public ImageFromDockerfile withBuildArg(final String key, final String value) { + this.buildArgs.put(key, value); + return this; + } + + public ImageFromDockerfile withBuildArgs(final Map args) { + this.buildArgs.putAll(args); + return this; + } + + /** + * Sets the target build stage to use. + * + * @param target the target build stage + */ + public ImageFromDockerfile withTarget(String target) { + this.target = Optional.of(target); + return this; + } + + /** + * Sets the Dockerfile to be used for this image. + * + * @param relativePathFromBuildContextDirectory relative path to the Dockerfile, relative to the image build context directory + * @deprecated It is recommended to use {@link #withDockerfile} instead because it honors .dockerignore files and + * will therefore be more efficient. Additionally, using {@link #withDockerfile} supports Dockerfiles that depend + * upon images from authenticated private registries. + */ + @Deprecated + public ImageFromDockerfile withDockerfilePath(String relativePathFromBuildContextDirectory) { + this.dockerFilePath = Optional.of(relativePathFromBuildContextDirectory); + return this; + } + + /** + * Sets the Dockerfile to be used for this image. Honors .dockerignore files for efficiency. + * Additionally, supports Dockerfiles that depend upon images from authenticated private registries. + * + * @param dockerfile path to Dockerfile on the test host. + */ + public ImageFromDockerfile withDockerfile(Path dockerfile) { + this.dockerfile = Optional.of(dockerfile); + return this; + } + + /** + * Allow low level modifications of {@link BuildImageCmd}. + * Warning: this does expose the underlying docker-java API so might change outside of our control. + * + * @param modifier {@link Consumer} of {@link BuildImageCmd}. + * @return this + */ + public ImageFromDockerfile withBuildImageCmdModifier(Consumer modifier) { + this.buildImageCmdModifiers.add(modifier); + return this; } } diff --git a/core/src/main/java/org/testcontainers/images/builder/Transferable.java b/core/src/main/java/org/testcontainers/images/builder/Transferable.java index eb245172596..32bf199b8a8 100644 --- a/core/src/main/java/org/testcontainers/images/builder/Transferable.java +++ b/core/src/main/java/org/testcontainers/images/builder/Transferable.java @@ -1,16 +1,59 @@ package org.testcontainers.images.builder; -import java.io.OutputStream; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.io.IOUtils; -public interface Transferable { +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.zip.Checksum; +public interface Transferable { int DEFAULT_FILE_MODE = 0100644; + int DEFAULT_DIR_MODE = 040755; + + static Transferable of(String string) { + return of(string.getBytes(StandardCharsets.UTF_8)); + } + + static Transferable of(String string, int fileMode) { + return of(string.getBytes(StandardCharsets.UTF_8), fileMode); + } + + static Transferable of(byte[] bytes) { + return of(bytes, DEFAULT_FILE_MODE); + } + + static Transferable of(byte[] bytes, int fileMode) { + return new Transferable() { + @Override + public long getSize() { + return bytes.length; + } + + @Override + public byte[] getBytes() { + return bytes; + } + + @Override + public void updateChecksum(Checksum checksum) { + checksum.update(bytes, 0, bytes.length); + } + + @Override + public int getFileMode() { + return fileMode; + } + }; + } + /** * Get file mode. Default is 0100644. - * @see Transferable#DEFAULT_FILE_MODE * * @return file mode + * @see Transferable#DEFAULT_FILE_MODE */ default int getFileMode() { return DEFAULT_FILE_MODE; @@ -26,7 +69,32 @@ default int getFileMode() { /** * transfer content of this Transferable to the output stream. Must not close the stream. * - * @param outputStream stream to output + * @param tarArchiveOutputStream stream to output + * @param destination */ - void transferTo(OutputStream outputStream); + default void transferTo(TarArchiveOutputStream tarArchiveOutputStream, final String destination) { + TarArchiveEntry tarEntry = new TarArchiveEntry(destination); + tarEntry.setSize(getSize()); + tarEntry.setMode(getFileMode()); + + try { + tarArchiveOutputStream.putArchiveEntry(tarEntry); + IOUtils.write(getBytes(), tarArchiveOutputStream); + tarArchiveOutputStream.closeArchiveEntry(); + } catch (IOException e) { + throw new RuntimeException("Can't transfer " + getDescription(), e); + } + } + + default byte[] getBytes() { + return new byte[0]; + } + + default String getDescription() { + return ""; + } + + default void updateChecksum(Checksum checksum) { + throw new UnsupportedOperationException("Provide implementation in subclass"); + } } diff --git a/core/src/main/java/org/testcontainers/images/builder/dockerfile/DockerfileBuilder.java b/core/src/main/java/org/testcontainers/images/builder/dockerfile/DockerfileBuilder.java index 17b13b3bac0..181978c9dd7 100644 --- a/core/src/main/java/org/testcontainers/images/builder/dockerfile/DockerfileBuilder.java +++ b/core/src/main/java/org/testcontainers/images/builder/dockerfile/DockerfileBuilder.java @@ -3,14 +3,28 @@ import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.testcontainers.images.builder.dockerfile.statement.Statement; -import org.testcontainers.images.builder.dockerfile.traits.*; +import org.testcontainers.images.builder.dockerfile.traits.AddStatementTrait; +import org.testcontainers.images.builder.dockerfile.traits.CmdStatementTrait; +import org.testcontainers.images.builder.dockerfile.traits.CopyStatementTrait; +import org.testcontainers.images.builder.dockerfile.traits.DockerfileBuilderTrait; +import org.testcontainers.images.builder.dockerfile.traits.EntryPointStatementTrait; +import org.testcontainers.images.builder.dockerfile.traits.EnvStatementTrait; +import org.testcontainers.images.builder.dockerfile.traits.ExposeStatementTrait; +import org.testcontainers.images.builder.dockerfile.traits.FromStatementTrait; +import org.testcontainers.images.builder.dockerfile.traits.LabelStatementTrait; +import org.testcontainers.images.builder.dockerfile.traits.RunStatementTrait; +import org.testcontainers.images.builder.dockerfile.traits.UserStatementTrait; +import org.testcontainers.images.builder.dockerfile.traits.VolumeStatementTrait; +import org.testcontainers.images.builder.dockerfile.traits.WorkdirStatementTrait; import java.util.ArrayList; import java.util.List; @Data @Slf4j -public class DockerfileBuilder implements DockerfileBuilderTrait, +public class DockerfileBuilder + implements + DockerfileBuilderTrait, FromStatementTrait, AddStatementTrait, CopyStatementTrait, @@ -27,7 +41,6 @@ public class DockerfileBuilder implements DockerfileBuilderTrait statements = new ArrayList<>(); public String build() { - StringBuilder builder = new StringBuilder(); for (Statement statement : statements) { diff --git a/core/src/main/java/org/testcontainers/images/builder/dockerfile/statement/Statement.java b/core/src/main/java/org/testcontainers/images/builder/dockerfile/statement/Statement.java index b36811a974d..2b9b005a579 100644 --- a/core/src/main/java/org/testcontainers/images/builder/dockerfile/statement/Statement.java +++ b/core/src/main/java/org/testcontainers/images/builder/dockerfile/statement/Statement.java @@ -7,5 +7,5 @@ public abstract class Statement { final String type; - abstract public void appendArguments(StringBuilder dockerfileStringBuilder); + public abstract void appendArguments(StringBuilder dockerfileStringBuilder); } diff --git a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/AddStatementTrait.java b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/AddStatementTrait.java index 44a35d68ba2..85b6b9ad6ea 100644 --- a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/AddStatementTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/AddStatementTrait.java @@ -3,7 +3,6 @@ import org.testcontainers.images.builder.dockerfile.statement.MultiArgsStatement; public interface AddStatementTrait & DockerfileBuilderTrait> { - default SELF add(String source, String destination) { return ((SELF) this).withStatement(new MultiArgsStatement("ADD", source, destination)); } diff --git a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/CmdStatementTrait.java b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/CmdStatementTrait.java index 054656af4d1..c9cc93c96c1 100644 --- a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/CmdStatementTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/CmdStatementTrait.java @@ -4,7 +4,6 @@ import org.testcontainers.images.builder.dockerfile.statement.SingleArgumentStatement; public interface CmdStatementTrait & DockerfileBuilderTrait> { - default SELF cmd(String command) { return ((SELF) this).withStatement(new SingleArgumentStatement("CMD", command)); } diff --git a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/CopyStatementTrait.java b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/CopyStatementTrait.java index 03e0cc7b461..0bdb2ef29ab 100644 --- a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/CopyStatementTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/CopyStatementTrait.java @@ -3,7 +3,6 @@ import org.testcontainers.images.builder.dockerfile.statement.MultiArgsStatement; public interface CopyStatementTrait & DockerfileBuilderTrait> { - default SELF copy(String source, String destination) { return ((SELF) this).withStatement(new MultiArgsStatement("COPY", source, destination)); } diff --git a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/DockerfileBuilderTrait.java b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/DockerfileBuilderTrait.java index 7a422da27d5..5fdc74ce1de 100644 --- a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/DockerfileBuilderTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/DockerfileBuilderTrait.java @@ -5,7 +5,6 @@ import java.util.List; public interface DockerfileBuilderTrait> { - List getStatements(); default SELF withStatement(Statement statement) { diff --git a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/EntryPointStatementTrait.java b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/EntryPointStatementTrait.java index c974e58e384..79a6e6315a0 100644 --- a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/EntryPointStatementTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/EntryPointStatementTrait.java @@ -4,7 +4,6 @@ import org.testcontainers.images.builder.dockerfile.statement.SingleArgumentStatement; public interface EntryPointStatementTrait & DockerfileBuilderTrait> { - default SELF entryPoint(String command) { return ((SELF) this).withStatement(new SingleArgumentStatement("ENTRYPOINT", command)); } diff --git a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/EnvStatementTrait.java b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/EnvStatementTrait.java index b02c8e71ec7..c1380304a34 100644 --- a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/EnvStatementTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/EnvStatementTrait.java @@ -6,7 +6,6 @@ import java.util.Map; public interface EnvStatementTrait & DockerfileBuilderTrait> { - default SELF env(String key, String value) { return env(Collections.singletonMap(key, value)); } diff --git a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/ExposeStatementTrait.java b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/ExposeStatementTrait.java index bebe243c8de..22dcbeee32b 100644 --- a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/ExposeStatementTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/ExposeStatementTrait.java @@ -6,8 +6,12 @@ import java.util.stream.Stream; public interface ExposeStatementTrait & DockerfileBuilderTrait> { - default SELF expose(Integer... ports) { - return ((SELF) this).withStatement(new SingleArgumentStatement("EXPOSE", Stream.of(ports).map(Object::toString).collect(Collectors.joining(" ")))); + return ((SELF) this).withStatement( + new SingleArgumentStatement( + "EXPOSE", + Stream.of(ports).map(Object::toString).collect(Collectors.joining(" ")) + ) + ); } } diff --git a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/FromStatementTrait.java b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/FromStatementTrait.java index c148bff106b..1ea411d206e 100644 --- a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/FromStatementTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/FromStatementTrait.java @@ -4,9 +4,8 @@ import org.testcontainers.utility.DockerImageName; public interface FromStatementTrait & DockerfileBuilderTrait> { - default SELF from(String dockerImageName) { - DockerImageName.validate(dockerImageName); + DockerImageName.parse(dockerImageName).assertValid(); return ((SELF) this).withStatement(new SingleArgumentStatement("FROM", dockerImageName)); } diff --git a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/LabelStatementTrait.java b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/LabelStatementTrait.java index 0b423453b33..4ed527051dd 100644 --- a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/LabelStatementTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/LabelStatementTrait.java @@ -6,7 +6,6 @@ import java.util.Map; public interface LabelStatementTrait & DockerfileBuilderTrait> { - default SELF label(String key, String value) { return label(Collections.singletonMap(key, value)); } diff --git a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/RunStatementTrait.java b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/RunStatementTrait.java index 74dfe1b00ab..f8fb064fb32 100644 --- a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/RunStatementTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/RunStatementTrait.java @@ -4,7 +4,6 @@ import org.testcontainers.images.builder.dockerfile.statement.SingleArgumentStatement; public interface RunStatementTrait & DockerfileBuilderTrait> { - default SELF run(String... commandParts) { return ((SELF) this).withStatement(new MultiArgsStatement("RUN", commandParts)); } diff --git a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/UserStatementTrait.java b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/UserStatementTrait.java index 57419ad7c5d..ca44d2d03be 100644 --- a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/UserStatementTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/UserStatementTrait.java @@ -3,7 +3,6 @@ import org.testcontainers.images.builder.dockerfile.statement.SingleArgumentStatement; public interface UserStatementTrait & DockerfileBuilderTrait> { - default SELF user(String user) { return ((SELF) this).withStatement(new SingleArgumentStatement("USER", user)); } diff --git a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/VolumeStatementTrait.java b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/VolumeStatementTrait.java index 2709adf32da..a8cd8621ee6 100644 --- a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/VolumeStatementTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/VolumeStatementTrait.java @@ -3,7 +3,6 @@ import org.testcontainers.images.builder.dockerfile.statement.MultiArgsStatement; public interface VolumeStatementTrait & DockerfileBuilderTrait> { - default SELF volume(String... volumes) { return ((SELF) this).withStatement(new MultiArgsStatement("VOLUME", volumes)); } diff --git a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/WorkdirStatementTrait.java b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/WorkdirStatementTrait.java index c4fd5386af8..ea2aaa1412c 100644 --- a/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/WorkdirStatementTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/dockerfile/traits/WorkdirStatementTrait.java @@ -3,7 +3,6 @@ import org.testcontainers.images.builder.dockerfile.statement.SingleArgumentStatement; public interface WorkdirStatementTrait & DockerfileBuilderTrait> { - default SELF workDir(String workdir) { return ((SELF) this).withStatement(new SingleArgumentStatement("WORKDIR", workdir)); } diff --git a/core/src/main/java/org/testcontainers/images/builder/traits/BuildContextBuilderTrait.java b/core/src/main/java/org/testcontainers/images/builder/traits/BuildContextBuilderTrait.java index 5ddee327cee..74ddf68b365 100644 --- a/core/src/main/java/org/testcontainers/images/builder/traits/BuildContextBuilderTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/traits/BuildContextBuilderTrait.java @@ -7,6 +7,5 @@ * */ public interface BuildContextBuilderTrait> { - SELF withFileFromTransferable(String path, Transferable transferable); } diff --git a/core/src/main/java/org/testcontainers/images/builder/traits/ClasspathTrait.java b/core/src/main/java/org/testcontainers/images/builder/traits/ClasspathTrait.java index 5617365f2cd..aadc3ccf7b9 100644 --- a/core/src/main/java/org/testcontainers/images/builder/traits/ClasspathTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/traits/ClasspathTrait.java @@ -9,7 +9,6 @@ * */ public interface ClasspathTrait & BuildContextBuilderTrait & FilesTrait> { - default SELF withFileFromClasspath(String path, String resourcePath) { final MountableFile mountableFile = MountableFile.forClasspathResource(resourcePath); diff --git a/core/src/main/java/org/testcontainers/images/builder/traits/DockerfileTrait.java b/core/src/main/java/org/testcontainers/images/builder/traits/DockerfileTrait.java index a4740220fc8..293b76a7c5c 100644 --- a/core/src/main/java/org/testcontainers/images/builder/traits/DockerfileTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/traits/DockerfileTrait.java @@ -1,45 +1,40 @@ package org.testcontainers.images.builder.traits; import lombok.Getter; -import org.apache.commons.io.IOUtils; import org.testcontainers.images.builder.Transferable; import org.testcontainers.images.builder.dockerfile.DockerfileBuilder; -import java.io.IOException; -import java.io.OutputStream; import java.util.function.Consumer; /** * BuildContextBuilder's trait for Dockerfile-based resources. * */ -public interface DockerfileTrait & BuildContextBuilderTrait & StringsTrait> { - +public interface DockerfileTrait< + SELF extends DockerfileTrait & BuildContextBuilderTrait & StringsTrait +> { default SELF withDockerfileFromBuilder(Consumer builderConsumer) { - DockerfileBuilder builder = new DockerfileBuilder(); builderConsumer.accept(builder); // return Transferable because we want to build Dockerfile's content lazily - return ((SELF) this).withFileFromTransferable("Dockerfile", new Transferable() { - - @Getter(lazy = true) - private final byte[] bytes = builder.build().getBytes(); - - @Override - public long getSize() { - return getBytes().length; - } - - @Override - public void transferTo(OutputStream outputStream) { - try { - IOUtils.write(getBytes(), outputStream); - } catch (IOException e) { - throw new RuntimeException("Can't transfer Dockerfile", e); + return ((SELF) this).withFileFromTransferable( + "Dockerfile", + new Transferable() { + @Getter(lazy = true) + private final byte[] bytes = builder.build().getBytes(); + + @Override + public long getSize() { + return getBytes().length; + } + + @Override + public String getDescription() { + return "Dockerfile: " + builder; + } } - } - }); + ); } } diff --git a/core/src/main/java/org/testcontainers/images/builder/traits/FilesTrait.java b/core/src/main/java/org/testcontainers/images/builder/traits/FilesTrait.java index d837d360a75..4c1204f752a 100644 --- a/core/src/main/java/org/testcontainers/images/builder/traits/FilesTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/traits/FilesTrait.java @@ -1,11 +1,8 @@ package org.testcontainers.images.builder.traits; -import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.MountableFile; import java.io.File; -import java.io.IOException; -import java.io.OutputStream; -import java.nio.file.Files; import java.nio.file.Path; /** @@ -13,37 +10,46 @@ * */ public interface FilesTrait & BuildContextBuilderTrait> { - + /** + * Adds file to tarball copied into container. + * @param path in tarball + * @param file in host filesystem + * @return self + */ default SELF withFileFromFile(String path, File file) { - return withFileFromPath(path, file.toPath()); + return withFileFromPath(path, file.toPath(), null); } + /** + * Adds file to tarball copied into container. + * @param path in tarball + * @param filePath in host filesystem + * @return self + */ default SELF withFileFromPath(String path, Path filePath) { - return ((SELF) this).withFileFromTransferable(path, new Transferable() { - - @Override - public long getSize() { - try { - return Files.size(filePath); - } catch (IOException e) { - throw new RuntimeException("Can't get size from " + filePath, e); - } - } - - @Override - public int getFileMode() { - return DEFAULT_FILE_MODE | (Files.isExecutable(filePath) ? 0755 : 0); - } + return withFileFromPath(path, filePath, null); + } - @Override - public void transferTo(OutputStream outputStream) { - try { - Files.copy(filePath, outputStream); - } catch (IOException e) { - throw new RuntimeException("Can't transfer file " + filePath, e); - } - } + /** + * Adds file with given mode to tarball copied into container. + * @param path in tarball + * @param file in host filesystem + * @param mode octal value of posix file mode (000..777) + * @return self + */ + default SELF withFileFromFile(String path, File file, Integer mode) { + return withFileFromPath(path, file.toPath(), mode); + } - }); + /** + * Adds file with given mode to tarball copied into container. + * @param path in tarball + * @param filePath in host filesystem + * @param mode octal value of posix file mode (000..777) + * @return self + */ + default SELF withFileFromPath(String path, Path filePath, Integer mode) { + final MountableFile mountableFile = MountableFile.forHostPath(filePath, mode); + return ((SELF) this).withFileFromTransferable(path, mountableFile); } } diff --git a/core/src/main/java/org/testcontainers/images/builder/traits/StringsTrait.java b/core/src/main/java/org/testcontainers/images/builder/traits/StringsTrait.java index 5a3f6bf0f95..68de3d67c14 100644 --- a/core/src/main/java/org/testcontainers/images/builder/traits/StringsTrait.java +++ b/core/src/main/java/org/testcontainers/images/builder/traits/StringsTrait.java @@ -1,37 +1,31 @@ package org.testcontainers.images.builder.traits; -import org.apache.commons.io.IOUtils; -import org.apache.commons.lang.StringUtils; +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; import org.testcontainers.images.builder.Transferable; -import java.io.IOException; -import java.io.OutputStream; - /** * BuildContextBuilder's trait for String-based manipulations. * */ public interface StringsTrait & BuildContextBuilderTrait> { - default SELF withFileFromString(String path, String content) { - return ((SELF) this).withFileFromTransferable(path, new Transferable() { - - byte[] bytes = content.getBytes(); - - @Override - public long getSize() { - return bytes.length; - } - - @Override - public void transferTo(OutputStream outputStream) { - try { - IOUtils.write(bytes, outputStream); - } catch (IOException e) { - throw new RuntimeException("Can't transfer string " + StringUtils.abbreviate(content, 100), e); + return ((SELF) this).withFileFromTransferable( + path, + new Transferable() { + @Getter + byte[] bytes = content.getBytes(); + + @Override + public long getSize() { + return bytes.length; + } + + @Override + public String getDescription() { + return "String: " + StringUtils.abbreviate(content, 100); + } } - } - - }); + ); } } diff --git a/core/src/main/java/org/testcontainers/jib/JibDockerClient.java b/core/src/main/java/org/testcontainers/jib/JibDockerClient.java new file mode 100644 index 00000000000..ddcf5fdc0ea --- /dev/null +++ b/core/src/main/java/org/testcontainers/jib/JibDockerClient.java @@ -0,0 +1,84 @@ +package org.testcontainers.jib; + +import com.github.dockerjava.api.command.InspectImageResponse; +import com.github.dockerjava.api.command.LoadImageCallback; +import com.google.cloud.tools.jib.api.DockerClient; +import com.google.cloud.tools.jib.api.ImageDetails; +import com.google.cloud.tools.jib.api.ImageReference; +import com.google.cloud.tools.jib.http.NotifyingOutputStream; +import com.google.cloud.tools.jib.image.ImageTarball; +import com.google.common.io.ByteStreams; +import lombok.Cleanup; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.UnstableAPI; +import org.testcontainers.images.RemoteDockerImage; +import org.testcontainers.utility.DockerImageName; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.function.Consumer; + +@UnstableAPI +class JibDockerClient implements DockerClient { + + private static JibDockerClient instance; + + private final com.github.dockerjava.api.DockerClient dockerClient = DockerClientFactory.lazyClient(); + + public static JibDockerClient instance() { + if (instance == null) { + instance = new JibDockerClient(); + } + + return instance; + } + + @Override + public boolean supported(Map map) { + return false; + } + + @Override + public String load(ImageTarball imageTarball, Consumer writtenByteCountListener) throws IOException { + @Cleanup + PipedInputStream in = new PipedInputStream(); + @Cleanup + PipedOutputStream out = new PipedOutputStream(in); + LoadImageCallback loadImage = this.dockerClient.loadImageAsyncCmd(in).exec(new LoadImageCallback()); + + try (NotifyingOutputStream stdin = new NotifyingOutputStream(out, writtenByteCountListener)) { + imageTarball.writeTo(stdin); + } + + return loadImage.awaitMessage(); + } + + @Override + public void save(ImageReference imageReference, Path outputPath, Consumer writtenByteCountListener) + throws IOException { + try ( + InputStream inputStream = this.dockerClient.saveImageCmd(imageReference.toString()).exec(); + InputStream stdout = new BufferedInputStream(inputStream); + OutputStream fileStream = new BufferedOutputStream(Files.newOutputStream(outputPath)); + NotifyingOutputStream notifyingFileStream = new NotifyingOutputStream(fileStream, writtenByteCountListener) + ) { + ByteStreams.copy(stdout, notifyingFileStream); + } + } + + @Override + public ImageDetails inspect(ImageReference imageReference) { + new RemoteDockerImage(DockerImageName.parse(imageReference.toString())).get(); + + InspectImageResponse response = this.dockerClient.inspectImageCmd(imageReference.toString()).exec(); + return new JibImageDetails(response.getSize(), response.getId(), response.getRootFS().getLayers()); + } +} diff --git a/core/src/main/java/org/testcontainers/jib/JibImage.java b/core/src/main/java/org/testcontainers/jib/JibImage.java new file mode 100644 index 00000000000..536d3fdf4e1 --- /dev/null +++ b/core/src/main/java/org/testcontainers/jib/JibImage.java @@ -0,0 +1,59 @@ +package org.testcontainers.jib; + +import com.google.cloud.tools.jib.api.Containerizer; +import com.google.cloud.tools.jib.api.DockerClient; +import com.google.cloud.tools.jib.api.DockerDaemonImage; +import com.google.cloud.tools.jib.api.Jib; +import com.google.cloud.tools.jib.api.JibContainer; +import com.google.cloud.tools.jib.api.JibContainerBuilder; +import lombok.SneakyThrows; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.utility.Base58; +import org.testcontainers.utility.LazyFuture; +import org.testcontainers.utility.ResourceReaper; + +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class JibImage extends LazyFuture { + + private final DockerClient dockerClient = JibDockerClient.instance(); + + private static final Map DEFAULT_LABELS = Stream + .of( + DockerClientFactory.DEFAULT_LABELS.entrySet().stream(), + ResourceReaper.instance().getLabels().entrySet().stream() + ) + .flatMap(Function.identity()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + private final String baseImage; + + private final Function jibContainerBuilderFn; + + public JibImage(String baseImage, Function jibContainerBuilderFn) { + this.baseImage = baseImage; + this.jibContainerBuilderFn = jibContainerBuilderFn; + } + + @SneakyThrows + @Override + protected String resolve() { + JibContainerBuilder containerBuilder = Jib.from(this.dockerClient, DockerDaemonImage.named(this.baseImage)); + Function applyLabelsFn = jibContainerBuilder -> { + for (Map.Entry entry : DEFAULT_LABELS.entrySet()) { + jibContainerBuilder.addLabel(entry.getKey(), entry.getValue()); + } + return jibContainerBuilder; + }; + JibContainer jibContainer = + this.jibContainerBuilderFn.andThen(applyLabelsFn) + .apply(containerBuilder) + .containerize( + Containerizer.to(this.dockerClient, DockerDaemonImage.named(Base58.randomString(8).toLowerCase())) + ); + return jibContainer.getTargetImage().toString(); + } +} diff --git a/core/src/main/java/org/testcontainers/jib/JibImageDetails.java b/core/src/main/java/org/testcontainers/jib/JibImageDetails.java new file mode 100644 index 00000000000..68ff6ba3f63 --- /dev/null +++ b/core/src/main/java/org/testcontainers/jib/JibImageDetails.java @@ -0,0 +1,42 @@ +package org.testcontainers.jib; + +import com.google.cloud.tools.jib.api.DescriptorDigest; +import com.google.cloud.tools.jib.api.ImageDetails; + +import java.security.DigestException; +import java.util.ArrayList; +import java.util.List; + +class JibImageDetails implements ImageDetails { + + private long size; + + private String imageId; + + private List layers; + + public JibImageDetails(long size, String imageId, List layers) { + this.size = size; + this.imageId = imageId; + this.layers = layers; + } + + @Override + public long getSize() { + return this.size; + } + + @Override + public DescriptorDigest getImageId() throws DigestException { + return DescriptorDigest.fromDigest(this.imageId); + } + + @Override + public List getDiffIds() throws DigestException { + List processedDiffIds = new ArrayList<>(this.layers.size()); + for (String diffId : this.layers) { + processedDiffIds.add(DescriptorDigest.fromDigest(diffId.trim())); + } + return processedDiffIds; + } +} diff --git a/core/src/main/java/org/testcontainers/lifecycle/Startable.java b/core/src/main/java/org/testcontainers/lifecycle/Startable.java new file mode 100644 index 00000000000..2ffa423d2e3 --- /dev/null +++ b/core/src/main/java/org/testcontainers/lifecycle/Startable.java @@ -0,0 +1,19 @@ +package org.testcontainers.lifecycle; + +import java.util.Collections; +import java.util.Set; + +public interface Startable extends AutoCloseable { + default Set getDependencies() { + return Collections.emptySet(); + } + + void start(); + + void stop(); + + @Override + default void close() { + stop(); + } +} diff --git a/core/src/main/java/org/testcontainers/lifecycle/Startables.java b/core/src/main/java/org/testcontainers/lifecycle/Startables.java new file mode 100644 index 00000000000..d61919af346 --- /dev/null +++ b/core/src/main/java/org/testcontainers/lifecycle/Startables.java @@ -0,0 +1,117 @@ +package org.testcontainers.lifecycle; + +import lombok.experimental.UtilityClass; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +@UtilityClass +public class Startables { + + private static final Executor EXECUTOR = Executors.newCachedThreadPool( + new ThreadFactory() { + private final AtomicLong COUNTER = new AtomicLong(0); + + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "testcontainers-lifecycle-" + COUNTER.getAndIncrement()); + thread.setDaemon(true); + return thread; + } + } + ); + + /** + * @see #deepStart(Stream) + */ + public CompletableFuture deepStart(Collection startables) { + return deepStart((Iterable) startables); + } + + /** + * @see #deepStart(Stream) + */ + public CompletableFuture deepStart(Iterable startables) { + return deepStart(StreamSupport.stream(startables.spliterator(), false)); + } + + /** + * @see #deepStart(Stream) + */ + public CompletableFuture deepStart(Startable... startables) { + return deepStart(Arrays.stream(startables)); + } + + /** + * Start every {@link Startable} recursively and asynchronously and join on the result. + * + * Performance note: + * The method uses and returns {@link CompletableFuture}s to resolve as many {@link Startable}s at once as possible. + * This way, for the following graph: + * / b \ + * a e + * c / + * d / + * "a", "c" and "d" will resolve in parallel, then "b". + * + * If we would call blocking {@link Startable#start()}, "e" would wait for "b", "b" for "a", and only then "c", and then "d". + * But, since "c" and "d" are independent from "a", there is no point in waiting for "a" to be resolved first. + * + * @param startables a {@link Stream} of {@link Startable}s to start and scan for transitive dependencies. + * @return a {@link CompletableFuture} that resolves once all {@link Startable}s have started. + */ + public CompletableFuture deepStart(Stream startables) { + return deepStart(new HashMap<>(), startables); + } + + /** + * + * @param started an intermediate storage for already started {@link Startable}s to prevent multiple starts. + * @param startables a {@link Stream} of {@link Startable}s to start and scan for transitive dependencies. + */ + private CompletableFuture deepStart( + Map> started, + Stream startables + ) { + CompletableFuture[] futures = startables + .sequential() + .map(it -> { + // avoid a recursive update in `computeIfAbsent` + Map> subStarted = new HashMap<>(started); + CompletableFuture future = started.computeIfAbsent( + it, + startable -> { + return deepStart(subStarted, startable.getDependencies().stream()) + .thenRunAsync(startable::start, EXECUTOR); + } + ); + started.putAll(subStarted); + return future; + }) + .toArray(CompletableFuture[]::new); + + return allOfFailfast(futures); + } + + private static CompletableFuture allOfFailfast(CompletableFuture[] futures) { + CompletableFuture result = CompletableFuture.allOf(futures); + for (CompletableFuture future : futures) { + future.whenComplete((t, ex) -> { + if (ex != null) { + result.completeExceptionally(ex); + } + }); + } + + return result; + } +} diff --git a/core/src/main/java/org/testcontainers/lifecycle/TestDescription.java b/core/src/main/java/org/testcontainers/lifecycle/TestDescription.java new file mode 100644 index 00000000000..3787707509a --- /dev/null +++ b/core/src/main/java/org/testcontainers/lifecycle/TestDescription.java @@ -0,0 +1,7 @@ +package org.testcontainers.lifecycle; + +public interface TestDescription { + String getTestId(); + + String getFilesystemFriendlyName(); +} diff --git a/core/src/main/java/org/testcontainers/lifecycle/TestLifecycleAware.java b/core/src/main/java/org/testcontainers/lifecycle/TestLifecycleAware.java new file mode 100644 index 00000000000..1e5a0258f14 --- /dev/null +++ b/core/src/main/java/org/testcontainers/lifecycle/TestLifecycleAware.java @@ -0,0 +1,9 @@ +package org.testcontainers.lifecycle; + +import java.util.Optional; + +public interface TestLifecycleAware { + default void beforeTest(TestDescription description) {} + + default void afterTest(TestDescription description, Optional throwable) {} +} diff --git a/core/src/main/java/org/testcontainers/utility/AuditLogger.java b/core/src/main/java/org/testcontainers/utility/AuditLogger.java new file mode 100644 index 00000000000..518144c4302 --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/AuditLogger.java @@ -0,0 +1,93 @@ +package org.testcontainers.utility; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.dockerjava.api.command.DockerCmd; +import com.google.common.base.Strings; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.MDC; + +import java.util.List; + +/** + * Logger for tracking potentially destructive actions, intended for usage in a shared Docker environment where + * traceability is needed. This class uses SLF4J, logging at TRACE level and capturing common fields as MDC fields. + *

+ * Users should configure their test logging to apply appropriate filters/storage so that these logs are + * captured appropriately. + */ +@Slf4j +@UtilityClass +public class AuditLogger { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + public static final String MDC_PREFIX = AuditLogger.class.getCanonicalName(); + + public static void doLog( + @NotNull String action, + @Nullable String image, + @Nullable String containerId, + @NotNull DockerCmd cmd + ) { + doLog(action, image, containerId, cmd, null); + } + + public static void doLog( + @NotNull String action, + @Nullable String image, + @Nullable String containerId, + @NotNull DockerCmd cmd, + @Nullable Exception e + ) { + if (!log.isTraceEnabled()) { + return; + } + + MDC.put(MDC_PREFIX + ".Action", Strings.nullToEmpty(action)); + MDC.put(MDC_PREFIX + ".Image", Strings.nullToEmpty(image)); + MDC.put(MDC_PREFIX + ".ContainerId", Strings.nullToEmpty(containerId)); + + try { + MDC.put(MDC_PREFIX + ".Command", objectMapper.writeValueAsString(cmd)); + } catch (JsonProcessingException ignored) {} + + if (e != null) { + MDC.put(MDC_PREFIX + ".Exception", e.getLocalizedMessage()); + log.trace("{} action with image: {}, containerId: {}", action, image, containerId, e); + } else { + log.trace("{} action with image: {}, containerId: {}", action, image, containerId); + } + + MDC.remove(MDC_PREFIX + ".Action"); + MDC.remove(MDC_PREFIX + ".Image"); + MDC.remove(MDC_PREFIX + ".ContainerId"); + MDC.remove(MDC_PREFIX + ".Command"); + MDC.remove(MDC_PREFIX + ".Exception"); + } + + public static void doComposeLog(@NotNull String[] commandParts, @Nullable List env) { + if (!log.isTraceEnabled()) { + return; + } + + MDC.put(MDC_PREFIX + ".Action", "COMPOSE"); + + if (env != null) { + MDC.put(MDC_PREFIX + ".Compose.Env", env.toString()); + } + + final String command = StringUtils.join(commandParts, ' '); + MDC.put(MDC_PREFIX + ".Compose.Command", command); + + log.trace("COMPOSE action with command: {}, env: {}", command, env); + + MDC.remove(MDC_PREFIX + ".Action"); + MDC.remove(MDC_PREFIX + ".Compose.Command"); + MDC.remove(MDC_PREFIX + ".Compose.Env"); + } +} diff --git a/core/src/main/java/org/testcontainers/utility/AuthConfigUtil.java b/core/src/main/java/org/testcontainers/utility/AuthConfigUtil.java new file mode 100644 index 00000000000..8ed91a5e34e --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/AuthConfigUtil.java @@ -0,0 +1,35 @@ +package org.testcontainers.utility; + +import com.github.dockerjava.api.model.AuthConfig; +import com.google.common.base.MoreObjects; +import com.google.common.base.Strings; +import lombok.experimental.UtilityClass; +import org.jetbrains.annotations.NotNull; + +/** + * TODO: Javadocs + */ +@UtilityClass +public class AuthConfigUtil { + + public static String toSafeString(AuthConfig authConfig) { + if (authConfig == null) { + return "null"; + } + + return MoreObjects + .toStringHelper(authConfig) + .add("username", authConfig.getUsername()) + .add("password", obfuscated(authConfig.getPassword())) + .add("auth", obfuscated(authConfig.getAuth())) + .add("email", authConfig.getEmail()) + .add("registryAddress", authConfig.getRegistryAddress()) + .add("registryToken", obfuscated(authConfig.getRegistrytoken())) + .toString(); + } + + @NotNull + private static String obfuscated(String value) { + return Strings.isNullOrEmpty(value) ? "blank" : "hidden non-blank value"; + } +} diff --git a/core/src/main/java/org/testcontainers/utility/Base58.java b/core/src/main/java/org/testcontainers/utility/Base58.java index 1f56978c7cc..2cbfc751f4d 100644 --- a/core/src/main/java/org/testcontainers/utility/Base58.java +++ b/core/src/main/java/org/testcontainers/utility/Base58.java @@ -7,8 +7,7 @@ */ public class Base58 { - private static final char[] ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" - .toCharArray(); + private static final char[] ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray(); private static final SecureRandom RANDOM = new SecureRandom(); diff --git a/core/src/main/java/org/testcontainers/utility/ClasspathScanner.java b/core/src/main/java/org/testcontainers/utility/ClasspathScanner.java new file mode 100644 index 00000000000..0c8addc2874 --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/ClasspathScanner.java @@ -0,0 +1,55 @@ +package org.testcontainers.utility; + +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; + +import java.net.URL; +import java.util.Collections; +import java.util.Comparator; +import java.util.Objects; +import java.util.stream.Stream; + +/** + * Utility for identifying resource files on classloaders. + */ +@Slf4j +class ClasspathScanner { + + @VisibleForTesting + static Stream scanFor(final String name, ClassLoader... classLoaders) { + return Stream + .of(classLoaders) + .flatMap(classLoader -> getAllPropertyFilesOnClassloader(classLoader, name)) + .filter(Objects::nonNull) + .sorted( + Comparator + .comparing(ClasspathScanner::filesFileSchemeFirst) // resolve 'local' files first + .thenComparing(URL::toString) // sort alphabetically for the sake of determinism + ) + .distinct(); + } + + private static Integer filesFileSchemeFirst(final URL t) { + return t.getProtocol().equals("file") ? 0 : 1; + } + + /** + * @param name the resource name to search for + * @return distinct, ordered stream of resources found by searching this class' classloader and the current thread's + * context classloader. Results are currently alphabetically sorted. + */ + static Stream scanFor(final String name) { + return scanFor(name, ClasspathScanner.class.getClassLoader(), Thread.currentThread().getContextClassLoader()); + } + + @Nullable + private static Stream getAllPropertyFilesOnClassloader(final ClassLoader it, final String s) { + try { + return Collections.list(it.getResources(s)).stream(); + } catch (Exception e) { + log.error("Unable to read configuration from classloader {} - this is probably a bug", it, e); + return Stream.empty(); + } + } +} diff --git a/core/src/main/java/org/testcontainers/utility/CommandLine.java b/core/src/main/java/org/testcontainers/utility/CommandLine.java index 65788357898..1bb8f827148 100644 --- a/core/src/main/java/org/testcontainers/utility/CommandLine.java +++ b/core/src/main/java/org/testcontainers/utility/CommandLine.java @@ -2,6 +2,7 @@ import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.zeroturnaround.exec.InvalidExitValueException; import org.zeroturnaround.exec.ProcessExecutor; import org.zeroturnaround.exec.ProcessResult; @@ -14,14 +15,12 @@ import java.util.concurrent.TimeoutException; import java.util.regex.Pattern; -import static org.slf4j.LoggerFactory.getLogger; - /** * Process execution utility methods. */ public class CommandLine { - private static final Logger LOGGER = getLogger(CommandLine.class); + private static final Logger LOGGER = LoggerFactory.getLogger(CommandLine.class); /** * Run a shell command synchronously. @@ -30,17 +29,11 @@ public class CommandLine { * @return the stdout output of the command */ public static String runShellCommand(String... command) { - String joinedCommand = String.join(" ", command); LOGGER.debug("Executing shell command: `{}`", joinedCommand); try { - ProcessResult result; - result = new ProcessExecutor() - .command(command) - .readOutput(true) - .exitValueNormal() - .execute(); + ProcessResult result = new ProcessExecutor().command(command).readOutput(true).exitValueNormal().execute(); return result.outputUTF8().trim(); } catch (IOException | InterruptedException | TimeoutException | InvalidExitValueException e) { @@ -56,7 +49,6 @@ public static String runShellCommand(String... command) { * @return whether the executable exists and is executable */ public static boolean executableExists(String executable) { - // First check if we've been given the full path already File directFile = new File(executable); if (directFile.exists() && directFile.canExecute()) { @@ -79,6 +71,7 @@ public static String[] getSystemPath() { } private static class ShellCommandException extends RuntimeException { + public ShellCommandException(String message, Exception e) { super(message, e); } diff --git a/core/src/main/java/org/testcontainers/utility/ComparableVersion.java b/core/src/main/java/org/testcontainers/utility/ComparableVersion.java index daa438cf54c..999c4b40b3b 100644 --- a/core/src/main/java/org/testcontainers/utility/ComparableVersion.java +++ b/core/src/main/java/org/testcontainers/utility/ComparableVersion.java @@ -1,20 +1,26 @@ package org.testcontainers.utility; +import com.google.common.annotations.VisibleForTesting; import org.jetbrains.annotations.NotNull; -public class ComparableVersion implements Comparable { +import java.util.ArrayList; +import java.util.List; - private final String[] parts; +public final class ComparableVersion implements Comparable { + + private final int[] parts; + + public static final ComparableVersion OS_VERSION = new ComparableVersion(System.getProperty("os.version")); public ComparableVersion(String version) { - this.parts = version.split("\\."); + this.parts = parseVersion(version); } @Override public int compareTo(@NotNull ComparableVersion other) { - for (int i=0; i otherPart) { return 1; } else if (thisPart < otherPart) { @@ -24,4 +30,44 @@ public int compareTo(@NotNull ComparableVersion other) { return 0; } + + public boolean isSemanticVersion() { + return parts.length > 0; + } + + public boolean isLessThan(String other) { + return this.compareTo(new ComparableVersion(other)) < 0; + } + + public boolean isGreaterThanOrEqualTo(String other) { + return this.compareTo(new ComparableVersion(other)) >= 0; + } + + @VisibleForTesting + static int[] parseVersion(final String version) { + final List parts = new ArrayList<>(5); + + int acc = 0; + for (final char c : version.toCharArray()) { + if (c == '.') { + parts.add(acc); + acc = 0; + } + + if (Character.isDigit(c)) { + acc = 10 * acc + Character.digit(c, 10); + } + } + + if (acc != 0) { + parts.add(acc); + } + + final int[] ret = new int[parts.size()]; + for (int i = 0; i < ret.length; i++) { + ret[i] = parts.get(i); + } + + return ret; + } } diff --git a/core/src/main/java/org/testcontainers/utility/ConfigurationFileImageNameSubstitutor.java b/core/src/main/java/org/testcontainers/utility/ConfigurationFileImageNameSubstitutor.java new file mode 100644 index 00000000000..ee7ab1d95b6 --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/ConfigurationFileImageNameSubstitutor.java @@ -0,0 +1,45 @@ +package org.testcontainers.utility; + +import com.google.common.annotations.VisibleForTesting; +import lombok.extern.slf4j.Slf4j; + +/** + * {@link ImageNameSubstitutor} which takes replacement image names from configuration. + * See {@link TestcontainersConfiguration} for the subset of image names which can be substituted using this mechanism. + */ +@Slf4j +final class ConfigurationFileImageNameSubstitutor extends ImageNameSubstitutor { + + private final TestcontainersConfiguration configuration; + + public ConfigurationFileImageNameSubstitutor() { + this(TestcontainersConfiguration.getInstance()); + } + + @VisibleForTesting + ConfigurationFileImageNameSubstitutor(TestcontainersConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public DockerImageName apply(final DockerImageName original) { + final DockerImageName result = configuration + .getConfiguredSubstituteImage(original) + .asCompatibleSubstituteFor(original); + + if (!result.equals(original)) { + log.warn( + "Image name {} was substituted by configuration to {}. This approach is deprecated and will be removed in the future", + original, + result + ); + } + + return result; + } + + @Override + protected String getDescription() { + return getClass().getSimpleName(); + } +} diff --git a/core/src/main/java/org/testcontainers/utility/DefaultImageNameSubstitutor.java b/core/src/main/java/org/testcontainers/utility/DefaultImageNameSubstitutor.java new file mode 100644 index 00000000000..a7755f8eea8 --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/DefaultImageNameSubstitutor.java @@ -0,0 +1,46 @@ +package org.testcontainers.utility; + +import com.google.common.annotations.VisibleForTesting; +import lombok.extern.slf4j.Slf4j; + +/** + * Testcontainers' default implementation of {@link ImageNameSubstitutor}. + * Delegates to {@link ConfigurationFileImageNameSubstitutor} followed by {@link PrefixingImageNameSubstitutor}. + */ +@Slf4j +final class DefaultImageNameSubstitutor extends ImageNameSubstitutor { + + private final ConfigurationFileImageNameSubstitutor configurationFileImageNameSubstitutor; + + private final PrefixingImageNameSubstitutor prefixingImageNameSubstitutor; + + public DefaultImageNameSubstitutor() { + configurationFileImageNameSubstitutor = new ConfigurationFileImageNameSubstitutor(); + prefixingImageNameSubstitutor = new PrefixingImageNameSubstitutor(); + } + + @VisibleForTesting + DefaultImageNameSubstitutor( + final ConfigurationFileImageNameSubstitutor configurationFileImageNameSubstitutor, + final PrefixingImageNameSubstitutor prefixingImageNameSubstitutor + ) { + this.configurationFileImageNameSubstitutor = configurationFileImageNameSubstitutor; + this.prefixingImageNameSubstitutor = prefixingImageNameSubstitutor; + } + + @Override + public DockerImageName apply(final DockerImageName original) { + return configurationFileImageNameSubstitutor.andThen(prefixingImageNameSubstitutor).apply(original); + } + + @Override + protected String getDescription() { + return ( + "DefaultImageNameSubstitutor (composite of '" + + configurationFileImageNameSubstitutor.getDescription() + + "' and '" + + prefixingImageNameSubstitutor.getDescription() + + "')" + ); + } +} diff --git a/core/src/main/java/org/testcontainers/utility/DockerImageName.java b/core/src/main/java/org/testcontainers/utility/DockerImageName.java index 42345223aff..b3babc29c91 100644 --- a/core/src/main/java/org/testcontainers/utility/DockerImageName.java +++ b/core/src/main/java/org/testcontainers/utility/DockerImageName.java @@ -1,20 +1,324 @@ package org.testcontainers.utility; +import com.google.common.net.HostAndPort; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.With; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.testcontainers.utility.Versioning.Sha256Versioning; +import org.testcontainers.utility.Versioning.TagVersioning; + +import java.util.regex.Pattern; + +@EqualsAndHashCode(exclude = { "rawName", "compatibleSubstituteFor" }) +@AllArgsConstructor(access = AccessLevel.PRIVATE) public final class DockerImageName { - public static void validate(String dockerImageName) throws IllegalArgumentException { - int repoSeparatorIndex = dockerImageName.indexOf('/'); - int tagSeparatorIndex; - if (repoSeparatorIndex == -1) { - tagSeparatorIndex = dockerImageName.indexOf(':'); + /* Regex patterns used for validation */ + private static final String ALPHA_NUMERIC = "[a-z0-9]+"; + + private static final String SEPARATOR = "([.]|_{1,2}|-+)"; + + private static final String REPO_NAME_PART = ALPHA_NUMERIC + "(" + SEPARATOR + ALPHA_NUMERIC + ")*"; + + private static final Pattern REPO_NAME = Pattern.compile(REPO_NAME_PART + "(/" + REPO_NAME_PART + ")*"); + + private static final String LIBRARY_PREFIX = "library/"; + + private final String rawName; + + @With + @Getter + private final String registry; + + @With + @Getter + private final String repository; + + @NotNull + @With(AccessLevel.PRIVATE) + private final Versioning versioning; + + @Nullable + @With(AccessLevel.PRIVATE) + private final DockerImageName compatibleSubstituteFor; + + /** + * Parses a docker image name from a provided string. + * + * @param fullImageName in standard Docker format, e.g. name:tag, + * some.registry/path/name:tag, + * some.registry/path/name@sha256:abcdef..., etc. + */ + public static DockerImageName parse(String fullImageName) { + return new DockerImageName(fullImageName); + } + + /** + * Parses a docker image name from a provided string. + * + * @param fullImageName in standard Docker format, e.g. name:tag, + * some.registry/path/name:tag, + * some.registry/path/name@sha256:abcdef..., etc. + * @deprecated use {@link DockerImageName#parse(String)} instead + */ + @Deprecated + public DockerImageName(String fullImageName) { + this.rawName = fullImageName; + final int slashIndex = fullImageName.indexOf('/'); + + String remoteName; + if ( + slashIndex == -1 || + ( + !fullImageName.substring(0, slashIndex).contains(".") && + !fullImageName.substring(0, slashIndex).contains(":") && + !fullImageName.substring(0, slashIndex).equals("localhost") + ) + ) { + registry = ""; + remoteName = fullImageName; + } else { + registry = fullImageName.substring(0, slashIndex); + remoteName = fullImageName.substring(slashIndex + 1); + } + + if (remoteName.contains("@sha256:")) { + String beforeDigest = remoteName.split("@sha256:")[0]; + if (beforeDigest.contains(":")) { + repository = beforeDigest.split(":")[0]; + String tag = beforeDigest.split(":", 2)[1]; + versioning = new Sha256Versioning(remoteName.split("@sha256:")[1], tag); + } else { + repository = beforeDigest; + versioning = new Sha256Versioning(remoteName.split("@sha256:")[1]); + } + } else if (remoteName.contains(":")) { + repository = remoteName.split(":")[0]; + versioning = new TagVersioning(remoteName.split(":")[1]); + } else { + repository = remoteName; + versioning = Versioning.ANY; + } + + compatibleSubstituteFor = null; + } + + /** + * Parses a docker image name from a provided string, and uses a separate provided version. + * + * @param nameWithoutTag in standard Docker format, e.g. name, + * some.registry/path/name, + * some.registry/path/name, etc. + * @param version a docker image version identifier, either as a tag or sha256 checksum, e.g. + * tag, + * sha256:abcdef.... + * @deprecated use {@link DockerImageName#parse(String)}.{@link DockerImageName#withTag(String)} instead + */ + @Deprecated + public DockerImageName(String nameWithoutTag, @NotNull String version) { + this.rawName = nameWithoutTag; + final int slashIndex = nameWithoutTag.indexOf('/'); + + String remoteName; + if ( + slashIndex == -1 || + ( + !nameWithoutTag.substring(0, slashIndex).contains(".") && + !nameWithoutTag.substring(0, slashIndex).contains(":") && + !nameWithoutTag.substring(0, slashIndex).equals("localhost") + ) + ) { + registry = ""; + remoteName = nameWithoutTag; + } else { + registry = nameWithoutTag.substring(0, slashIndex); + remoteName = nameWithoutTag.substring(slashIndex + 1); + } + + if (version.startsWith("sha256:")) { + repository = remoteName; + versioning = new Sha256Versioning(version.replace("sha256:", "")); + } else { + repository = remoteName; + versioning = new TagVersioning(version); + } + + compatibleSubstituteFor = null; + } + + /** + * @return the unversioned (non 'tag') part of this name + */ + public String getUnversionedPart() { + if (!"".equals(registry)) { + return registry + "/" + repository; + } else { + return repository; + } + } + + /** + * @return the versioned part of this name (tag or sha256). When both tag and digest are present, + * the tag is returned. + */ + public String getVersionPart() { + if (versioning instanceof Sha256Versioning) { + String tag = ((Sha256Versioning) versioning).getTag(); + if (tag != null) { + return tag; + } + } + return versioning.toString(); + } + + /** + * @return the sha256 digest if present, or null + */ + @Nullable + public String getDigest() { + if (versioning instanceof Sha256Versioning) { + return versioning.toString(); + } + return null; + } + + /** + * @return canonical name for the image + */ + public String asCanonicalNameString() { + if (versioning instanceof Sha256Versioning) { + String tag = ((Sha256Versioning) versioning).getTag(); + if (tag != null) { + return getUnversionedPart() + ":" + tag + "@" + versioning; + } + } + return getUnversionedPart() + versioning.getSeparator() + getVersionPart(); + } + + @Override + public String toString() { + return asCanonicalNameString(); + } + + /** + * Is the image name valid? + * + * @throws IllegalArgumentException if not valid + */ + public void assertValid() { + //noinspection UnstableApiUsage + HostAndPort.fromString(registry); // return value ignored - this throws if registry is not a valid host:port string + if (!REPO_NAME.matcher(repository).matches()) { + throw new IllegalArgumentException(repository + " is not a valid Docker image name (in " + rawName + ")"); + } + if (!versioning.isValid()) { + throw new IllegalArgumentException( + versioning + " is not a valid image versioning identifier (in " + rawName + ")" + ); + } + } + + /** + * @param newTag version tag for the copy to use + * @return an immutable copy of this {@link DockerImageName} with the new version tag + */ + public DockerImageName withTag(final String newTag) { + return withVersioning(new TagVersioning(newTag)); + } + + /** + * Declare that this {@link DockerImageName} is a compatible substitute for another image - i.e. that this image + * behaves as the other does, and is compatible with Testcontainers' assumptions about the other image. + * + * @param otherImageName the image name of the other image + * @return an immutable copy of this {@link DockerImageName} with the compatibility declaration attached. + */ + public DockerImageName asCompatibleSubstituteFor(String otherImageName) { + return withCompatibleSubstituteFor(DockerImageName.parse(otherImageName)); + } + + /** + * Declare that this {@link DockerImageName} is a compatible substitute for another image - i.e. that this image + * behaves as the other does, and is compatible with Testcontainers' assumptions about the other image. + * + * @param otherImageName the image name of the other image + * @return an immutable copy of this {@link DockerImageName} with the compatibility declaration attached. + */ + public DockerImageName asCompatibleSubstituteFor(DockerImageName otherImageName) { + return withCompatibleSubstituteFor(otherImageName); + } + + /** + * Test whether this {@link DockerImageName} has declared compatibility with another image (set using + * {@link DockerImageName#asCompatibleSubstituteFor(String)} or + * {@link DockerImageName#asCompatibleSubstituteFor(DockerImageName)}. + *

+ * If a version tag part is present in the other image name, the tags must exactly match, unless it + * is 'latest'. If a version part is not present in the other image name, the tag contents are ignored. + * + * @param other the other image that we are trying to test compatibility with + * @return whether this image has declared compatibility. + */ + public boolean isCompatibleWith(DockerImageName other) { + // Make sure we always compare against a version of the image name containing the LIBRARY_PREFIX + String finalImageName; + if (this.repository.startsWith(LIBRARY_PREFIX)) { + finalImageName = this.repository; } else { - tagSeparatorIndex = dockerImageName.indexOf(':', repoSeparatorIndex); + finalImageName = LIBRARY_PREFIX + this.repository; + } + DockerImageName imageWithLibraryPrefix = DockerImageName.parse(finalImageName); + if (other.equals(this) || imageWithLibraryPrefix.equals(this)) { + return true; } - if (tagSeparatorIndex == -1) { - throw new IllegalArgumentException("No image tag was specified in docker image name " + - "(" + dockerImageName + "). Please provide a tag; this may be 'latest' or a specific version"); + + if (this.compatibleSubstituteFor == null) { + return false; } + + return this.compatibleSubstituteFor.isCompatibleWith(other); } - private DockerImageName() {} + /** + * Behaves as {@link DockerImageName#isCompatibleWith(DockerImageName)} but throws an exception + * rather than returning false if a mismatch is detected. + * + * @param anyOthers the other image(s) that we are trying to check compatibility with. If more + * than one is provided, this method will check compatibility with at least one + * of them. + * @throws IllegalStateException if {@link DockerImageName#isCompatibleWith(DockerImageName)} + * returns false + */ + public void assertCompatibleWith(DockerImageName... anyOthers) { + if (anyOthers.length == 0) { + throw new IllegalArgumentException("anyOthers parameter must be non-empty"); + } + + for (DockerImageName anyOther : anyOthers) { + if (this.isCompatibleWith(anyOther)) { + return; + } + } + + final DockerImageName exampleOther = anyOthers[0]; + + throw new IllegalStateException( + String.format( + "Failed to verify that image '%s' is a compatible substitute for '%s'. This generally means that " + + "you are trying to use an image that Testcontainers has not been designed to use. If this is " + + "deliberate, and if you are confident that the image is compatible, you should declare " + + "compatibility in code using the `asCompatibleSubstituteFor` method. For example:\n" + + " DockerImageName myImage = DockerImageName.parse(\"%s\").asCompatibleSubstituteFor(\"%s\");\n" + + "and then use `myImage` instead.", + this.rawName, + exampleOther.rawName, + this.rawName, + exampleOther.rawName + ) + ); + } } diff --git a/core/src/main/java/org/testcontainers/utility/DockerLoggerFactory.java b/core/src/main/java/org/testcontainers/utility/DockerLoggerFactory.java index 6c013dcd692..e45c5867dd9 100644 --- a/core/src/main/java/org/testcontainers/utility/DockerLoggerFactory.java +++ b/core/src/main/java/org/testcontainers/utility/DockerLoggerFactory.java @@ -9,10 +9,13 @@ public final class DockerLoggerFactory { public static Logger getLogger(String dockerImageName) { - if ("UTF-8".equals(System.getProperty("file.encoding"))) { - return LoggerFactory.getLogger("\uD83D\uDC33 [" + dockerImageName + "]"); + final String abbreviatedName; + if (dockerImageName.contains("@sha256")) { + abbreviatedName = dockerImageName.substring(0, dockerImageName.indexOf("@sha256") + 14) + "..."; } else { - return LoggerFactory.getLogger("docker[" + dockerImageName + "]"); + abbreviatedName = dockerImageName; } + + return LoggerFactory.getLogger("tc." + abbreviatedName); } } diff --git a/core/src/main/java/org/testcontainers/utility/DockerMachineClient.java b/core/src/main/java/org/testcontainers/utility/DockerMachineClient.java index 240a6d5afb4..1a2b4aafe74 100644 --- a/core/src/main/java/org/testcontainers/utility/DockerMachineClient.java +++ b/core/src/main/java/org/testcontainers/utility/DockerMachineClient.java @@ -1,47 +1,44 @@ package org.testcontainers.utility; import lombok.NonNull; - -import org.apache.commons.lang.SystemUtils; +import org.apache.commons.lang3.SystemUtils; import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.Arrays; import java.util.List; import java.util.Optional; -import static java.util.Arrays.asList; -import static org.slf4j.LoggerFactory.getLogger; -import static org.testcontainers.utility.CommandLine.executableExists; -import static org.testcontainers.utility.CommandLine.runShellCommand; - /** * Created by rnorth on 27/10/2015. */ public class DockerMachineClient { private static DockerMachineClient instance; - private static final Logger LOGGER = getLogger(DockerMachineClient.class); + + private static final Logger LOGGER = LoggerFactory.getLogger(DockerMachineClient.class); private static final String executableName; + static { - if(SystemUtils.IS_OS_WINDOWS) { - executableName = "docker-machine.exe"; - } else { - executableName = "docker-machine"; - } + if (SystemUtils.IS_OS_WINDOWS) { + executableName = "docker-machine.exe"; + } else { + executableName = "docker-machine"; + } } /** * Private constructor */ - private DockerMachineClient() { - } + private DockerMachineClient() {} /** * Obtain an instance of the DockerMachineClient wrapper. * * @return the singleton instance of DockerMachineClient */ - public synchronized static DockerMachineClient instance() { + public static synchronized DockerMachineClient instance() { if (instance == null) { instance = new DockerMachineClient(); } @@ -50,12 +47,12 @@ public synchronized static DockerMachineClient instance() { } public boolean isInstalled() { - return executableExists(executableName); + return CommandLine.executableExists(executableName); } public Optional getDefaultMachine() { - String ls = runShellCommand(executableName, "ls", "-q"); - List machineNames = asList(ls.split("\n")); + String ls = CommandLine.runShellCommand(executableName, "ls", "-q"); + List machineNames = Arrays.asList(ls.split("\n")); String envMachineName = System.getenv("DOCKER_MACHINE_NAME"); @@ -66,7 +63,10 @@ public Optional getDefaultMachine() { LOGGER.debug("DOCKER_MACHINE_NAME is not set; Using 'default' docker-machine", envMachineName); return Optional.of("default"); } else if (machineNames.size() > 0) { - LOGGER.debug("DOCKER_MACHINE_NAME is not set and no machine named 'default' found; Using first machine found with `docker-machine ls`: {}", machineNames.get(0)); + LOGGER.debug( + "DOCKER_MACHINE_NAME is not set and no machine named 'default' found; Using first machine found with `docker-machine ls`: {}", + machineNames.get(0) + ); return Optional.of(machineNames.get(0)); } else { return Optional.empty(); @@ -76,16 +76,24 @@ public Optional getDefaultMachine() { public void ensureMachineRunning(@NonNull String machineName) { if (!isMachineRunning(machineName)) { LOGGER.info("Docker-machine '{}' is not running. Will start it now", machineName); - runShellCommand("docker-machine", "start", machineName); + CommandLine.runShellCommand("docker-machine", "start", machineName); } } + /** + * @deprecated Use getDockerDaemonUrl(@NonNull String machineName) for connection to docker-machine + */ + @Deprecated public String getDockerDaemonIpAddress(@NonNull String machineName) { - return runShellCommand(executableName, "ip", machineName); + return CommandLine.runShellCommand(executableName, "ip", machineName); + } + + public String getDockerDaemonUrl(@NonNull String machineName) { + return CommandLine.runShellCommand(executableName, "url", machineName); } public boolean isMachineRunning(String machineName) { - String status = runShellCommand("docker-machine", "status", machineName); + String status = CommandLine.runShellCommand("docker-machine", "status", machineName); return status.trim().equalsIgnoreCase("running"); } diff --git a/core/src/main/java/org/testcontainers/utility/DockerStatus.java b/core/src/main/java/org/testcontainers/utility/DockerStatus.java index 63b19db410b..00f71249dd1 100644 --- a/core/src/main/java/org/testcontainers/utility/DockerStatus.java +++ b/core/src/main/java/org/testcontainers/utility/DockerStatus.java @@ -33,15 +33,16 @@ public class DockerStatus { * @param now the time to consider as the current time * @return true if we can conclude that the container is running, false otherwise */ - public static boolean isContainerRunning(InspectContainerResponse.ContainerState state, - Duration minimumRunningDuration, - Instant now) { + public static boolean isContainerRunning( + InspectContainerResponse.ContainerState state, + Duration minimumRunningDuration, + Instant now + ) { if (state.getRunning()) { if (minimumRunningDuration == null) { return true; } - Instant startedAt = DateTimeFormatter.ISO_INSTANT.parse( - state.getStartedAt(), Instant::from); + Instant startedAt = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(state.getStartedAt(), Instant::from); if (startedAt.isBefore(now.minus(minimumRunningDuration))) { return true; @@ -57,7 +58,6 @@ public static boolean isContainerRunning(InspectContainerResponse.ContainerState * @return true if we can conclude that the container has started but is now stopped, false otherwise. */ public static boolean isContainerStopped(InspectContainerResponse.ContainerState state) { - // get some preconditions out of the way if (state.getRunning() || state.getPaused()) { return false; @@ -72,10 +72,12 @@ public static boolean isContainerStopped(InspectContainerResponse.ContainerState public static boolean isDockerTimestampNonEmpty(String dockerTimestamp) { // This is a defensive approach. Current versions of Docker use the DOCKER_TIMESTAMP_ZERO value, but // that could change. - return dockerTimestamp != null - && !dockerTimestamp.isEmpty() - && !dockerTimestamp.equals(DOCKER_TIMESTAMP_ZERO) - && DateTimeFormatter.ISO_INSTANT.parse(dockerTimestamp, Instant::from).getEpochSecond() >= 0L; + return ( + dockerTimestamp != null && + !dockerTimestamp.isEmpty() && + !dockerTimestamp.equals(DOCKER_TIMESTAMP_ZERO) && + DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(dockerTimestamp, Instant::from).getEpochSecond() >= 0L + ); } public static boolean isContainerExitCodeSuccess(InspectContainerResponse.ContainerState state) { diff --git a/core/src/main/java/org/testcontainers/utility/DynamicPollInterval.java b/core/src/main/java/org/testcontainers/utility/DynamicPollInterval.java new file mode 100644 index 00000000000..043befe04bb --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/DynamicPollInterval.java @@ -0,0 +1,43 @@ +package org.testcontainers.utility; + +import org.awaitility.pollinterval.PollInterval; + +import java.time.Duration; +import java.time.Instant; + +/** + * Awaitility {@link org.awaitility.pollinterval.PollInterval} that takes execution time into consideration, + * to allow a constant poll-interval, as opposed to Awaitility's default poll-delay behaviour. + * + * @deprecated For internal usage only. + */ +@Deprecated +public class DynamicPollInterval implements PollInterval { + + final Duration interval; + + Instant lastTimestamp; + + private DynamicPollInterval(Duration interval) { + this.interval = interval; + lastTimestamp = Instant.now(); + } + + public static DynamicPollInterval of(Duration duration) { + return new DynamicPollInterval(duration); + } + + public static DynamicPollInterval ofMillis(long millis) { + return DynamicPollInterval.of(Duration.ofMillis(millis)); + } + + @Override + public Duration next(int pollCount, Duration previousDuration) { + Instant now = Instant.now(); + Duration executionDuration = Duration.between(lastTimestamp, now); + + Duration result = interval.minusMillis(Math.min(interval.toMillis(), executionDuration.toMillis())); + lastTimestamp = now.plus(result); + return result; + } +} diff --git a/core/src/main/java/org/testcontainers/utility/ImageNameSubstitutor.java b/core/src/main/java/org/testcontainers/utility/ImageNameSubstitutor.java new file mode 100644 index 00000000000..e46d091d657 --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/ImageNameSubstitutor.java @@ -0,0 +1,192 @@ +package org.testcontainers.utility; + +import com.google.common.annotations.VisibleForTesting; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.UnstableAPI; + +import java.util.ServiceLoader; +import java.util.function.Function; +import java.util.stream.StreamSupport; + +/** + * An image name substitutor converts a Docker image name, as may be specified in code, to an alternative name. + * This is intended to provide a way to override image names, for example to enforce pulling of images from a private + * registry. + *

+ * This is marked as @{@link UnstableAPI} as this API is new. While we do not think major changes will be required, we + * will react to feedback if necessary. + */ +@Slf4j +@UnstableAPI +public abstract class ImageNameSubstitutor implements Function { + + @VisibleForTesting + static ImageNameSubstitutor instance; + + @VisibleForTesting + static ImageNameSubstitutor defaultImplementation = new DefaultImageNameSubstitutor(); + + public static synchronized ImageNameSubstitutor instance() { + return instance(Thread.currentThread().getContextClassLoader()); + } + + @VisibleForTesting + static synchronized ImageNameSubstitutor instance(ClassLoader classLoader) { + if (instance == null) { + ImageNameSubstitutor configuredInstance = getImageNameSubstitutor(classLoader); + + if (configuredInstance != null) { + log.debug( + "Attempting to instantiate an ImageNameSubstitutor with class: {}", + configuredInstance.getClass().getCanonicalName() + ); + + log.info("Found configured ImageNameSubstitutor: {}", configuredInstance.getDescription()); + + instance = + new ChainedImageNameSubstitutor( + wrapWithLogging(defaultImplementation), + wrapWithLogging(configuredInstance) + ); + } else { + instance = wrapWithLogging(defaultImplementation); + } + + log.info("Image name substitution will be performed by: {}", instance.getDescription()); + } + + return instance; + } + + private static ImageNameSubstitutor getImageNameSubstitutor(ClassLoader classLoader) { + final String configuredClassName = TestcontainersConfiguration.getInstance().getImageSubstitutorClassName(); + + if (configuredClassName != null) { + try { + return (ImageNameSubstitutor) classLoader.loadClass(configuredClassName).getConstructor().newInstance(); + } catch (Exception e) { + throw new IllegalArgumentException( + "Configured Image Substitutor could not be loaded: " + configuredClassName, + e + ); + } + } + + return StreamSupport + .stream(ServiceLoader.load(ImageNameSubstitutor.class, classLoader).spliterator(), false) + .findFirst() + .orElse(null); + } + + public static ImageNameSubstitutor noop() { + return new NoopImageNameSubstitutor(); + } + + private static ImageNameSubstitutor wrapWithLogging(final ImageNameSubstitutor wrappedInstance) { + return new LogWrappedImageNameSubstitutor(wrappedInstance); + } + + /** + * Substitute a {@link DockerImageName} for another, for example to replace a generic Docker Hub image name with a + * private registry copy of the image. + * + * @param original original name to be replaced + * @return a replacement name, or the original, as appropriate + */ + public abstract DockerImageName apply(DockerImageName original); + + /** + * @return a human-readable description of the substitutor + */ + protected abstract String getDescription(); + + /** + * Wrapper substitutor which logs which substitutions have been performed. + */ + static class LogWrappedImageNameSubstitutor extends ImageNameSubstitutor { + + @VisibleForTesting + final ImageNameSubstitutor wrappedInstance; + + public LogWrappedImageNameSubstitutor(final ImageNameSubstitutor wrappedInstance) { + this.wrappedInstance = wrappedInstance; + } + + @Override + public DockerImageName apply(final DockerImageName original) { + final DockerImageName replacementImage = wrappedInstance.apply(original); + + if (!replacementImage.equals(original)) { + log.info( + "Using {} as a substitute image for {} (using image substitutor: {})", + replacementImage.asCanonicalNameString(), + original.asCanonicalNameString(), + wrappedInstance.getDescription() + ); + return replacementImage; + } else { + log.debug( + "Did not find a substitute image for {} (using image substitutor: {})", + original.asCanonicalNameString(), + wrappedInstance.getDescription() + ); + return original; + } + } + + @Override + protected String getDescription() { + return wrappedInstance.getDescription(); + } + } + + /** + * Wrapper substitutor that passes the original image name through a default substitutor and then the configured one + */ + static class ChainedImageNameSubstitutor extends ImageNameSubstitutor { + + private final ImageNameSubstitutor defaultInstance; + + private final ImageNameSubstitutor configuredInstance; + + public ChainedImageNameSubstitutor( + ImageNameSubstitutor defaultInstance, + ImageNameSubstitutor configuredInstance + ) { + this.defaultInstance = defaultInstance; + this.configuredInstance = configuredInstance; + } + + @Override + public DockerImageName apply(DockerImageName original) { + return defaultInstance.andThen(configuredInstance).apply(original); + } + + @Override + protected String getDescription() { + return String.format( + "Chained substitutor of '%s' and then '%s'", + defaultInstance.getDescription(), + configuredInstance.getDescription() + ); + } + + @Override + public String toString() { + return getDescription(); + } + } + + private static class NoopImageNameSubstitutor extends ImageNameSubstitutor { + + @Override + public DockerImageName apply(DockerImageName original) { + return original; + } + + @Override + protected String getDescription() { + return "No-op substitutor"; + } + } +} diff --git a/core/src/main/java/org/testcontainers/utility/JVMHookResourceReaper.java b/core/src/main/java/org/testcontainers/utility/JVMHookResourceReaper.java new file mode 100644 index 00000000000..39fbc5e10e1 --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/JVMHookResourceReaper.java @@ -0,0 +1,62 @@ +package org.testcontainers.utility; + +import com.github.dockerjava.api.model.Container; +import com.github.dockerjava.api.model.PruneType; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * A {@link ResourceReaper} implementation that uses {@link Runtime#addShutdownHook(Thread)} + * to cleanup containers. + */ +class JVMHookResourceReaper extends ResourceReaper { + + @Override + public void init() { + setHook(); + } + + @Override + public synchronized void performCleanup() { + super.performCleanup(); + synchronized (DEATH_NOTE) { + DEATH_NOTE.forEach(filters -> prune(PruneType.CONTAINERS, filters)); + DEATH_NOTE.forEach(filters -> prune(PruneType.NETWORKS, filters)); + DEATH_NOTE.forEach(filters -> prune(PruneType.VOLUMES, filters)); + DEATH_NOTE.forEach(filters -> prune(PruneType.IMAGES, filters)); + } + } + + private void prune(PruneType pruneType, List> filters) { + String[] labels = filters + .stream() + .filter(it -> "label".equals(it.getKey())) + .map(Map.Entry::getValue) + .toArray(String[]::new); + switch (pruneType) { + // Docker only prunes stopped containers, so we have to do it manually + case CONTAINERS: + List containers = dockerClient + .listContainersCmd() + .withFilter("label", Arrays.asList(labels)) + .withShowAll(true) + .exec(); + + containers + .parallelStream() + .forEach(container -> { + dockerClient + .removeContainerCmd(container.getId()) + .withForce(true) + .withRemoveVolumes(true) + .exec(); + }); + break; + default: + dockerClient.pruneCmd(pruneType).withLabelFilter(labels).exec(); + break; + } + } +} diff --git a/core/src/main/java/org/testcontainers/utility/LazyFuture.java b/core/src/main/java/org/testcontainers/utility/LazyFuture.java index b0aa4346cbb..3fb2ed704c0 100644 --- a/core/src/main/java/org/testcontainers/utility/LazyFuture.java +++ b/core/src/main/java/org/testcontainers/utility/LazyFuture.java @@ -2,10 +2,12 @@ import lombok.AccessLevel; import lombok.Getter; -import lombok.experimental.Delegate; import org.rnorth.ducttape.timeouts.Timeouts; -import java.util.concurrent.*; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; /** * Future implementation with lazy result evaluation in the same Thread as caller. @@ -14,31 +16,37 @@ */ public abstract class LazyFuture implements Future { - @Delegate(excludes = Excludes.class) - private final Future delegate = CompletableFuture.completedFuture(null); - @Getter(value = AccessLevel.MODULE, lazy = true) private final T resolvedValue = resolve(); - abstract protected T resolve(); + protected abstract T resolve(); + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return false; + } + + @Override + public boolean isCancelled() { + return false; + } @Override - public T get() throws InterruptedException, ExecutionException { + public boolean isDone() { + return ((AtomicReference) resolvedValue).get() != null; + } + + @Override + public T get() { return getResolvedValue(); } @Override - public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { + public T get(long timeout, TimeUnit unit) throws TimeoutException { try { return Timeouts.getWithTimeout((int) timeout, unit, this::get); } catch (org.rnorth.ducttape.TimeoutException e) { throw new TimeoutException(e.getMessage()); } } - - private interface Excludes { - T get(); - - T get(long timeout, TimeUnit unit); - } } diff --git a/core/src/main/java/org/testcontainers/utility/LicenseAcceptance.java b/core/src/main/java/org/testcontainers/utility/LicenseAcceptance.java new file mode 100644 index 00000000000..9fe8d9db8e6 --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/LicenseAcceptance.java @@ -0,0 +1,43 @@ +package org.testcontainers.utility; + +import com.google.common.base.Charsets; +import com.google.common.io.Resources; +import lombok.experimental.UtilityClass; + +import java.net.URL; +import java.util.List; + +/** + * Utility class to ensure that licenses have been accepted by the developer. + */ +@UtilityClass +public class LicenseAcceptance { + + private static final String ACCEPTANCE_FILE_NAME = "container-license-acceptance.txt"; + + public static void assertLicenseAccepted(final String imageName) { + try { + final URL url = Resources.getResource(ACCEPTANCE_FILE_NAME); + final List acceptedLicences = Resources.readLines(url, Charsets.UTF_8); + + if (acceptedLicences.stream().map(String::trim).anyMatch(imageName::equals)) { + return; + } + } catch (Exception ignored) { + // suppressed + } + + throw new IllegalStateException( + "The image " + + imageName + + " requires you to accept a license agreement. " + + "Please place a file at the root of the classpath named " + + ACCEPTANCE_FILE_NAME + + ", e.g. at " + + "src/test/resources/" + + ACCEPTANCE_FILE_NAME + + ". This file should contain the line:\n " + + imageName + ); + } +} diff --git a/core/src/main/java/org/testcontainers/utility/LogUtils.java b/core/src/main/java/org/testcontainers/utility/LogUtils.java index 30db5a0a972..e5912563cd9 100644 --- a/core/src/main/java/org/testcontainers/utility/LogUtils.java +++ b/core/src/main/java/org/testcontainers/utility/LogUtils.java @@ -2,36 +2,102 @@ import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.command.LogContainerCmd; +import lombok.SneakyThrows; import lombok.experimental.UtilityClass; import org.testcontainers.containers.output.FrameConsumerResultCallback; import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.containers.output.ToStringConsumer; +import org.testcontainers.containers.output.WaitingConsumer; +import java.io.Closeable; +import java.io.IOException; import java.util.function.Consumer; -import static org.testcontainers.containers.output.OutputFrame.OutputType.STDERR; -import static org.testcontainers.containers.output.OutputFrame.OutputType.STDOUT; - /** * Provides utility methods for logging. */ @UtilityClass public class LogUtils { + + /** + * Attach a log consumer to a container's log outputs in follow mode. The consumer will receive all previous + * and all future log frames of the specified type(s). + * + * @param dockerClient a Docker client + * @param containerId container ID to attach to + * @param consumer a consumer of {@link OutputFrame}s + * @param types types of {@link OutputFrame} to receive + */ + public void followOutput( + DockerClient dockerClient, + String containerId, + Consumer consumer, + OutputFrame.OutputType... types + ) { + attachConsumer(dockerClient, containerId, consumer, true, types); + } + + /** + * Attach a log consumer to a container's log outputs in follow mode. The consumer will receive all previous + * and all future log frames (both stdout and stderr). + * + * @param dockerClient a Docker client + * @param containerId container ID to attach to + * @param consumer a consumer of {@link OutputFrame}s + */ + public void followOutput(DockerClient dockerClient, String containerId, Consumer consumer) { + followOutput(dockerClient, containerId, consumer, OutputFrame.OutputType.STDOUT, OutputFrame.OutputType.STDERR); + } + /** - * {@inheritDoc} + * Retrieve all previous log outputs for a container of the specified type(s). + * + * @param dockerClient a Docker client + * @param containerId container ID to attach to + * @param types types of {@link OutputFrame} to receive + * @return all previous output frames (stdout/stderr being separated by newline characters) */ - public void followOutput(DockerClient dockerClient, String containerId, - Consumer consumer, OutputFrame.OutputType... types) { + @SneakyThrows(IOException.class) + public String getOutput(DockerClient dockerClient, String containerId, OutputFrame.OutputType... types) { + if (containerId == null) { + return ""; + } + + if (types.length == 0) { + types = new OutputFrame.OutputType[] { OutputFrame.OutputType.STDOUT, OutputFrame.OutputType.STDERR }; + } + + final ToStringConsumer consumer = new ToStringConsumer(); + final WaitingConsumer wait = new WaitingConsumer(); + try (Closeable closeable = attachConsumer(dockerClient, containerId, consumer.andThen(wait), false, types)) { + wait.waitUntilEnd(); + return consumer.toUtf8String(); + } + } - final LogContainerCmd cmd = dockerClient.logContainerCmd(containerId) - .withFollowStream(true); + private static Closeable attachConsumer( + DockerClient dockerClient, + String containerId, + Consumer consumer, + boolean followStream, + OutputFrame.OutputType... types + ) { + final LogContainerCmd cmd = dockerClient + .logContainerCmd(containerId) + .withFollowStream(followStream) + .withSince(0); final FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); for (OutputFrame.OutputType type : types) { callback.addConsumer(type, consumer); - if (type == STDOUT) cmd.withStdOut(true); - if (type == STDERR) cmd.withStdErr(true); + if (type == OutputFrame.OutputType.STDOUT) { + cmd.withStdOut(true); + } + if (type == OutputFrame.OutputType.STDERR) { + cmd.withStdErr(true); + } } - cmd.exec(callback); + return cmd.exec(callback); } } diff --git a/core/src/main/java/org/testcontainers/utility/MountableFile.java b/core/src/main/java/org/testcontainers/utility/MountableFile.java index 31ee6008751..a8a9f465a19 100644 --- a/core/src/main/java/org/testcontainers/utility/MountableFile.java +++ b/core/src/main/java/org/testcontainers/utility/MountableFile.java @@ -1,44 +1,67 @@ package org.testcontainers.utility; import com.google.common.base.Charsets; +import lombok.AccessLevel; import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang.SystemUtils; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.archivers.tar.TarConstants; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.SystemUtils; import org.jetbrains.annotations.NotNull; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.UnstableAPI; +import org.testcontainers.images.builder.Transferable; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.io.UncheckedIOException; import java.io.UnsupportedEncodingException; -import java.net.URI; -import java.net.URISyntaxException; import java.net.URL; import java.net.URLDecoder; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Enumeration; import java.util.HashSet; import java.util.Set; import java.util.jar.JarEntry; import java.util.jar.JarFile; - -import static lombok.AccessLevel.PRIVATE; -import static org.testcontainers.utility.PathUtils.recursiveDeleteDir; +import java.util.stream.Stream; +import java.util.zip.Checksum; /** * An abstraction over files and classpath resources aimed at encapsulating all the complexity of generating * a path that the Docker daemon is about to create a volume mount for. */ -@RequiredArgsConstructor(access = PRIVATE) +@RequiredArgsConstructor(access = AccessLevel.PACKAGE) @Slf4j -public class MountableFile { +public class MountableFile implements Transferable { + + private static final String TESTCONTAINERS_TMP_DIR_PREFIX = ".testcontainers-tmp-"; + + private static final String OS_MAC_TMP_DIR = "/tmp"; + + private static final int BASE_FILE_MODE = 0100000; + + private static final int BASE_DIR_MODE = 0040000; private final String path; + private final Integer forcedFileMode; + @Getter(lazy = true) private final String resolvedPath = resolvePath(); + @Getter(lazy = true) + private final String filesystemPath = resolveFilesystemPath(); + + private String resourcePath; + /** * Obtains a {@link MountableFile} corresponding to a resource on the classpath (including resources in JAR files) * @@ -46,7 +69,7 @@ public class MountableFile { * @return a {@link MountableFile} that may be used to obtain a mountable path */ public static MountableFile forClasspathResource(@NotNull final String resourceName) { - return new MountableFile(getClasspathResource(resourceName, new HashSet<>()).toString()); + return forClasspathResource(resourceName, null); } /** @@ -56,34 +79,57 @@ public static MountableFile forClasspathResource(@NotNull final String resourceN * @return a {@link MountableFile} that may be used to obtain a mountable path */ public static MountableFile forHostPath(@NotNull final String path) { - return new MountableFile(new File(path).toURI().toString()); + return forHostPath(path, null); } /** - * Obtain a path that the Docker daemon should be able to use to volume mount a file/resource - * into a container. If this is a classpath resource residing in a JAR, it will be extracted to - * a temporary location so that the Docker daemon is able to access it. + * Obtains a {@link MountableFile} corresponding to a file on the docker host filesystem. * - * @return a volume-mountable path. + * @param path the path to the resource + * @return a {@link MountableFile} that may be used to obtain a mountable path */ - private String resolvePath() { - String result; - if (path.contains(".jar!")) { - result = extractClassPathResourceToTempLocation(this.path); - } else { - result = unencodeResourceURIToFilePath(path); - } + public static MountableFile forHostPath(final Path path) { + return forHostPath(path, null); + } - if (SystemUtils.IS_OS_WINDOWS) { - result = PathUtils.createMinGWPath(result); - } + /** + * Obtains a {@link MountableFile} corresponding to a resource on the classpath (including resources in JAR files) + * + * @param resourceName the classpath path to the resource + * @param mode octal value of posix file mode (000..777) + * @return a {@link MountableFile} that may be used to obtain a mountable path + */ + public static MountableFile forClasspathResource(@NotNull final String resourceName, Integer mode) { + return new MountableFile(getClasspathResource(resourceName, new HashSet<>()).toString(), mode); + } - return result; + /** + * Obtains a {@link MountableFile} corresponding to a file on the docker host filesystem. + * + * @param path the path to the resource + * @param mode octal value of posix file mode (000..777) + * @return a {@link MountableFile} that may be used to obtain a mountable path + */ + public static MountableFile forHostPath(@NotNull final String path, Integer mode) { + return forHostPath(Paths.get(path), mode); } - @NotNull - private static URL getClasspathResource(@NotNull final String resourcePath, @NotNull final Set classLoaders) { + /** + * Obtains a {@link MountableFile} corresponding to a file on the docker host filesystem. + * + * @param path the path to the resource + * @param mode octal value of posix file mode (000..777) + * @return a {@link MountableFile} that may be used to obtain a mountable path + */ + public static MountableFile forHostPath(final Path path, Integer mode) { + return new MountableFile(path.toAbsolutePath().toString(), mode); + } + @NotNull + private static URL getClasspathResource( + @NotNull final String resourcePath, + @NotNull final Set classLoaders + ) { final Set classLoadersToSearch = new HashSet<>(classLoaders); // try context and system classloaders as well classLoadersToSearch.add(Thread.currentThread().getContextClassLoader()); @@ -91,6 +137,10 @@ private static URL getClasspathResource(@NotNull final String resourcePath, @Not classLoadersToSearch.add(MountableFile.class.getClassLoader()); for (final ClassLoader classLoader : classLoadersToSearch) { + if (classLoader == null) { + continue; + } + URL resource = classLoader.getResource(resourcePath); if (resource != null) { return resource; @@ -105,18 +155,74 @@ private static URL getClasspathResource(@NotNull final String resourcePath, @Not } } - throw new IllegalArgumentException("Resource with path " + resourcePath + " could not be found on any of these classloaders: " + classLoaders); + throw new IllegalArgumentException( + "Resource with path " + + resourcePath + + " could not be found on any of these classloaders: " + + classLoadersToSearch + ); } private static String unencodeResourceURIToFilePath(@NotNull final String resource) { try { // Convert any url-encoded characters (e.g. spaces) back into unencoded form - return new URI(resource).getPath(); - } catch (URISyntaxException e) { + return URLDecoder + .decode(resource.replaceAll("\\+", "%2B"), Charsets.UTF_8.name()) + .replaceFirst("jar:", "") + .replaceFirst("file:", "") + .replaceAll("!.*", ""); + } catch (UnsupportedEncodingException e) { throw new IllegalStateException(e); } } + /** + * Obtain a path that the Docker daemon should be able to use to volume mount a file/resource + * into a container. If this is a classpath resource residing in a JAR, it will be extracted to + * a temporary location so that the Docker daemon is able to access it. + * + * @return a volume-mountable path. + */ + private String resolvePath() { + String result = getResourcePath(); + + // Special case for Windows + if (SystemUtils.IS_OS_WINDOWS && result.startsWith("/")) { + // Remove leading / + result = result.substring(1); + } + + return result; + } + + /** + * Obtain a path in local filesystem that the Docker daemon should be able to use to volume mount a file/resource + * into a container. If this is a classpath resource residing in a JAR, it will be extracted to + * a temporary location so that the Docker daemon is able to access it. + * + * TODO: rename method accordingly and check if really needed like this + * + * @return + */ + private String resolveFilesystemPath() { + String result = getResourcePath(); + + if (SystemUtils.IS_OS_WINDOWS && result.startsWith("/")) { + result = PathUtils.createMinGWPath(result).substring(1); + } + + return result; + } + + private String getResourcePath() { + if (path.contains(".jar!")) { + resourcePath = extractClassPathResourceToTempLocation(this.path); + } else { + resourcePath = unencodeResourceURIToFilePath(path); + } + return resourcePath; + } + /** * Extract a file or directory tree from a JAR file to a temporary location. * This allows Docker to mount classpath resources as files. @@ -125,17 +231,11 @@ private static String unencodeResourceURIToFilePath(@NotNull final String resour * @return the path of the temporary file/directory */ private String extractClassPathResourceToTempLocation(final String hostPath) { - File tmpLocation = new File(".testcontainers-tmp-" + Base58.randomString(5)); + File tmpLocation = createTempDirectory(); //noinspection ResultOfMethodCallIgnored tmpLocation.delete(); - String jarPath = hostPath.replaceFirst("jar:", "").replaceFirst("file:", "").replaceAll("!.*", ""); - String urldecodedJarPath; - try { - urldecodedJarPath = URLDecoder.decode(jarPath, Charsets.UTF_8.name()); - } catch (UnsupportedEncodingException e) { - throw new IllegalArgumentException("Could not URLDecode path with UTF-8 encoding: " + hostPath, e); - } + String urldecodedJarPath = unencodeResourceURIToFilePath(hostPath); String internalPath = hostPath.replaceAll("[^!]*!/", ""); try (JarFile jarFile = new JarFile(urldecodedJarPath)) { @@ -145,52 +245,223 @@ private String extractClassPathResourceToTempLocation(final String hostPath) { JarEntry entry = entries.nextElement(); final String name = entry.getName(); if (name.startsWith(internalPath)) { - log.debug("Copying classpath resource(s) from {} to {} to permit Docker to bind", - hostPath, - tmpLocation); + log.debug( + "Copying classpath resource(s) from {} to {} to permit Docker to bind", + hostPath, + tmpLocation + ); copyFromJarToLocation(jarFile, entry, internalPath, tmpLocation); } } - } catch (IOException e) { - throw new IllegalStateException("Failed to process JAR file when extracting classpath resource: " + hostPath, e); + throw new IllegalStateException( + "Failed to process JAR file when extracting classpath resource: " + hostPath, + e + ); } // Mark temporary files/dirs for deletion at JVM shutdown deleteOnExit(tmpLocation.toPath()); - return tmpLocation.getAbsolutePath(); + try { + return tmpLocation.getCanonicalPath(); + } catch (IOException e) { + throw new IllegalStateException(e); + } } - @SuppressWarnings("ResultOfMethodCallIgnored") - private void copyFromJarToLocation(final JarFile jarFile, - final JarEntry entry, - final String fromRoot, - final File toRoot) throws IOException { + private File createTempDirectory() { + try { + if (SystemUtils.IS_OS_MAC) { + return Files.createTempDirectory(Paths.get(OS_MAC_TMP_DIR), TESTCONTAINERS_TMP_DIR_PREFIX).toFile(); + } + return Files.createTempDirectory(TESTCONTAINERS_TMP_DIR_PREFIX).toFile(); + } catch (IOException e) { + return new File(TESTCONTAINERS_TMP_DIR_PREFIX + Base58.randomString(5)); + } + } + @SuppressWarnings("ResultOfMethodCallIgnored") + private void copyFromJarToLocation( + final JarFile jarFile, + final JarEntry entry, + final String fromRoot, + final File toRoot + ) throws IOException { String destinationName = entry.getName().replaceFirst(fromRoot, ""); File newFile = new File(toRoot, destinationName); - log.debug("Copying resource {} from JAR file {}", - fromRoot, - jarFile.getName()); + log.debug("Copying resource {} from JAR file {}", fromRoot, jarFile.getName()); if (!entry.isDirectory()) { // Create parent directories - newFile.mkdirs(); - newFile.delete(); + Path parent = newFile.getAbsoluteFile().toPath().getParent(); + parent.toFile().mkdirs(); newFile.deleteOnExit(); try (InputStream is = jarFile.getInputStream(entry)) { Files.copy(is, newFile.toPath()); } catch (IOException e) { - log.error("Failed to extract classpath resource " + entry.getName() + " from JAR file " + jarFile.getName(), e); + log.error( + "Failed to extract classpath resource " + entry.getName() + " from JAR file " + jarFile.getName(), + e + ); throw e; } } } private void deleteOnExit(final Path path) { - Runtime.getRuntime().addShutdownHook(new Thread(() -> recursiveDeleteDir(path))); + Runtime + .getRuntime() + .addShutdownHook( + new Thread(DockerClientFactory.TESTCONTAINERS_THREAD_GROUP, () -> PathUtils.recursiveDeleteDir(path)) + ); + } + + /** + * {@inheritDoc} + */ + @Override + public void transferTo(final TarArchiveOutputStream outputStream, String destinationPathInTar) { + recursiveTar(destinationPathInTar, this.getResolvedPath(), this.getResolvedPath(), outputStream); + } + + /* + * Recursively copies a file/directory into a TarArchiveOutputStream + */ + private void recursiveTar( + String entryFilename, + String rootPath, + String itemPath, + TarArchiveOutputStream tarArchive + ) { + try { + final File sourceFile = new File(itemPath).getCanonicalFile(); // e.g. /foo/bar/baz + final File sourceRootFile = new File(rootPath).getCanonicalFile(); // e.g. /foo + final String relativePathToSourceFile = sourceRootFile + .toPath() + .relativize(sourceFile.toPath()) + .toFile() + .toString(); // e.g. /bar/baz + + final String tarEntryFilename; + if (relativePathToSourceFile.isEmpty()) { + tarEntryFilename = entryFilename; // entry filename e.g. xyz => xyz + } else { + tarEntryFilename = entryFilename + "/" + relativePathToSourceFile; // entry filename e.g. /xyz/bar/baz => /foo/bar/baz + } + + final TarArchiveEntry tarEntry = new TarArchiveEntry(sourceFile, tarEntryFilename.replaceAll("^/", "")); + + // TarArchiveEntry automatically sets the mode for file/directory, but we can update to ensure that the mode is set exactly (inc executable bits) + tarEntry.setMode(getUnixFileMode(itemPath)); + tarArchive.putArchiveEntry(tarEntry); + + if (sourceFile.isFile()) { + Files.copy(sourceFile.toPath(), tarArchive); + } + // a directory entry merely needs to exist in the TAR file - there is no data stored yet + tarArchive.closeArchiveEntry(); + + final File[] children = sourceFile.listFiles(); + if (children != null) { + // recurse into child files/directories + for (final File child : children) { + recursiveTar( + entryFilename, + sourceRootFile.getCanonicalPath(), + child.getCanonicalPath(), + tarArchive + ); + } + } + } catch (IOException e) { + log.error("Error when copying TAR file entry: {}", itemPath, e); + throw new UncheckedIOException(e); // fail fast + } + } + + @Override + public long getSize() { + final File file = new File(this.getResolvedPath()); + if (file.isFile()) { + return file.length(); + } else { + return 0; + } + } + + @Override + public String getDescription() { + return this.getResolvedPath(); + } + + @Override + public void updateChecksum(Checksum checksum) { + File file = new File(getResolvedPath()); + checksumFile(file, checksum); + } + + @SneakyThrows(IOException.class) + private void checksumFile(File file, Checksum checksum) { + Path path = file.toPath(); + checksum.update(MountableFile.getUnixFileMode(path)); + if (file.isDirectory()) { + try (Stream stream = Files.walk(path)) { + stream + .filter(it -> it != path) + .forEach(it -> { + checksumFile(it.toFile(), checksum); + }); + } + } else { + FileUtils.checksum(file, checksum); + } + } + + @Override + public int getFileMode() { + return getUnixFileMode(this.getResolvedPath()); + } + + private int getUnixFileMode(final String pathAsString) { + final Path path = Paths.get(pathAsString); + if (this.forcedFileMode != null) { + return this.getModeValue(path); + } + return getUnixFileMode(path); + } + + @UnstableAPI + public static int getUnixFileMode(final Path path) { + try { + int unixMode = (int) Files.readAttributes(path, "unix:mode").get("mode"); + // Truncate mode bits for z/OS + if ( + "OS/390".equals(SystemUtils.OS_NAME) || + "z/OS".equals(SystemUtils.OS_NAME) || + "zOS".equals(SystemUtils.OS_NAME) + ) { + unixMode &= TarConstants.MAXID; + unixMode |= Files.isDirectory(path) ? 040000 : 0100000; + } + return unixMode; + } catch (IOException | UnsupportedOperationException e) { + // fallback for non-posix environments + int mode = DEFAULT_FILE_MODE; + if (Files.isDirectory(path)) { + mode = DEFAULT_DIR_MODE; + } else if (Files.isExecutable(path)) { + mode |= 0111; // equiv to +x for user/group/others + } + + return mode; + } + } + + private int getModeValue(final Path path) { + int result = Files.isDirectory(path) ? BASE_DIR_MODE : BASE_FILE_MODE; + return result | this.forcedFileMode; } } diff --git a/core/src/main/java/org/testcontainers/utility/PathUtils.java b/core/src/main/java/org/testcontainers/utility/PathUtils.java index 2dcf9db8a8d..7f8b5925689 100644 --- a/core/src/main/java/org/testcontainers/utility/PathUtils.java +++ b/core/src/main/java/org/testcontainers/utility/PathUtils.java @@ -23,21 +23,23 @@ public class PathUtils { */ public static void recursiveDeleteDir(final @NonNull Path directory) { try { - Files.walkFileTree(directory, new SimpleFileVisitor() { - @Override - public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { - Files.delete(dir); - return FileVisitResult.CONTINUE; - } + Files.walkFileTree( + directory, + new SimpleFileVisitor() { + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { - Files.delete(file); - return FileVisitResult.CONTINUE; + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } } - }); - } catch (IOException ignored) { - } + ); + } catch (IOException ignored) {} } /** @@ -66,8 +68,10 @@ public static String createMinGWPath(String path) { } // drive-letter must be lower case - mingwPath = "//" + Character.toLowerCase(mingwPath.charAt(driveLetterIndex)) + - mingwPath.substring(driveLetterIndex + 1); + mingwPath = + "//" + + Character.toLowerCase(mingwPath.charAt(driveLetterIndex)) + + mingwPath.substring(driveLetterIndex + 1); mingwPath = mingwPath.replace(":", ""); return mingwPath; } diff --git a/core/src/main/java/org/testcontainers/utility/PrefixingImageNameSubstitutor.java b/core/src/main/java/org/testcontainers/utility/PrefixingImageNameSubstitutor.java new file mode 100644 index 00000000000..7cf082000a4 --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/PrefixingImageNameSubstitutor.java @@ -0,0 +1,54 @@ +package org.testcontainers.utility; + +import com.google.common.annotations.VisibleForTesting; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * An {@link ImageNameSubstitutor} which applies a prefix to all image names, e.g. a private registry host and path. + * The prefix may be set via an environment variable (TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX) or an equivalent + * configuration file entry (see {@link TestcontainersConfiguration}). + */ +@NoArgsConstructor +@Slf4j +final class PrefixingImageNameSubstitutor extends ImageNameSubstitutor { + + @VisibleForTesting + static final String PREFIX_PROPERTY_KEY = "hub.image.name.prefix"; + + private TestcontainersConfiguration configuration = TestcontainersConfiguration.getInstance(); + + @VisibleForTesting + PrefixingImageNameSubstitutor(final TestcontainersConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public DockerImageName apply(DockerImageName original) { + final String configuredPrefix = configuration.getEnvVarOrProperty(PREFIX_PROPERTY_KEY, ""); + + if (configuredPrefix.isEmpty()) { + log.debug("No prefix is configured"); + return original; + } + + boolean isAHubImage = original.getRegistry().isEmpty(); + if (!isAHubImage) { + log.debug("Image {} is not a Docker Hub image - not applying registry/repository change", original); + return original; + } + + log.debug("Applying changes to image name {}: applying prefix '{}'", original, configuredPrefix); + + DockerImageName prefixAsImage = DockerImageName.parse(configuredPrefix); + + return original + .withRegistry(prefixAsImage.getRegistry()) + .withRepository(prefixAsImage.getRepository() + original.getRepository()); + } + + @Override + protected String getDescription() { + return getClass().getSimpleName(); + } +} diff --git a/core/src/main/java/org/testcontainers/utility/RegistryAuthLocator.java b/core/src/main/java/org/testcontainers/utility/RegistryAuthLocator.java new file mode 100644 index 00000000000..3caabb6f0d4 --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/RegistryAuthLocator.java @@ -0,0 +1,420 @@ +package org.testcontainers.utility; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.dockerjava.api.exception.NotFoundException; +import com.github.dockerjava.api.model.AuthConfig; +import com.google.common.annotations.VisibleForTesting; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.SystemUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.DockerClientFactory; +import org.zeroturnaround.exec.InvalidResultException; +import org.zeroturnaround.exec.ProcessExecutor; +import org.zeroturnaround.exec.ProcessResult; +import org.zeroturnaround.exec.stream.LogOutputStream; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.util.Base64; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Utility to look up registry authentication information for an image. + */ +public class RegistryAuthLocator { + + private static final Logger log = LoggerFactory.getLogger(RegistryAuthLocator.class); + + private static final String DEFAULT_REGISTRY_NAME = "https://index.docker.io/v1/"; + + private static final String DOCKER_AUTH_ENV_VAR = "DOCKER_AUTH_CONFIG"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static RegistryAuthLocator instance; + + private final String commandPathPrefix; + + private final String commandExtension; + + private final File configFile; + + private final String configEnv; + + private final Map> cache = new ConcurrentHashMap<>(); + + /** + * key - credential helper's name + * value - helper's response for "credentials not found" use case + */ + private final Map CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE; + + @VisibleForTesting + RegistryAuthLocator( + File configFile, + String configEnv, + String commandPathPrefix, + String commandExtension, + Map notFoundMessageHolderReference + ) { + this.configFile = configFile; + this.configEnv = configEnv; + this.commandPathPrefix = commandPathPrefix; + this.commandExtension = commandExtension; + + this.CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE = notFoundMessageHolderReference; + } + + /** + */ + protected RegistryAuthLocator() { + final String dockerConfigLocation = System + .getenv() + .getOrDefault("DOCKER_CONFIG", System.getProperty("user.home") + "/.docker"); + this.configFile = new File(dockerConfigLocation + "/config.json"); + this.configEnv = System.getenv(DOCKER_AUTH_ENV_VAR); + this.commandPathPrefix = ""; + this.commandExtension = ""; + + this.CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE = new HashMap<>(); + } + + public static synchronized RegistryAuthLocator instance() { + if (instance == null) { + instance = new RegistryAuthLocator(); + } + + return instance; + } + + @VisibleForTesting + static void setInstance(RegistryAuthLocator overrideInstance) { + instance = overrideInstance; + } + + /** + * Looks up an AuthConfig for a given image name. + *

+ * Lookup is performed in following order, as per + * https://docs.docker.com/engine/reference/commandline/cli/: + *

    + *
  1. {@code credHelpers}
  2. + *
  3. {@code credsStore}
  4. + *
  5. Hard-coded Base64 encoded auth in {@code auths}
  6. + *
  7. otherwise, if no credentials have been found then behaviour falls back to docker-java's + * implementation
  8. + *
+ * + * @param dockerImageName image name to be looked up (potentially including a registry URL part) + * @param defaultAuthConfig an AuthConfig object that should be returned if there is no overriding authentication available for images that are looked up + * @return an AuthConfig that is applicable to this specific image OR the defaultAuthConfig. + */ + public AuthConfig lookupAuthConfig(DockerImageName dockerImageName, AuthConfig defaultAuthConfig) { + final String registryName = effectiveRegistryName(dockerImageName); + log.debug("Looking up auth config for image: {} at registry: {}", dockerImageName, registryName); + + final Optional cachedAuth = cache.computeIfAbsent( + registryName, + __ -> lookupUncachedAuthConfig(registryName, dockerImageName) + ); + + if (cachedAuth.isPresent()) { + log.debug("Cached auth found: [{}]", AuthConfigUtil.toSafeString(cachedAuth.get())); + return cachedAuth.get(); + } else { + log.debug( + "No matching Auth Configs - falling back to defaultAuthConfig [{}]", + AuthConfigUtil.toSafeString(defaultAuthConfig) + ); + // otherwise, defaultAuthConfig should already contain any credentials available + return defaultAuthConfig; + } + } + + private Optional lookupUncachedAuthConfig(String registryName, DockerImageName dockerImageName) { + try { + final JsonNode config = getDockerAuthConfig(); + log.debug("registryName [{}] for dockerImageName [{}]", registryName, dockerImageName); + + // use helper preferentially (per https://docs.docker.com/engine/reference/commandline/cli/) + final AuthConfig helperAuthConfig = authConfigUsingHelper(config, registryName); + if (helperAuthConfig != null) { + log.debug("found helper auth config [{}]", AuthConfigUtil.toSafeString(helperAuthConfig)); + return Optional.of(helperAuthConfig); + } + // no credsHelper to use, using credsStore: + final AuthConfig storeAuthConfig = authConfigUsingStore(config, registryName); + if (storeAuthConfig != null) { + log.debug("found creds store auth config [{}]", AuthConfigUtil.toSafeString(storeAuthConfig)); + return Optional.of(storeAuthConfig); + } + // fall back to base64 encoded auth hardcoded in config file + final AuthConfig existingAuthConfig = findExistingAuthConfig(config, registryName); + if (existingAuthConfig != null) { + log.debug("found existing auth config [{}]", AuthConfigUtil.toSafeString(existingAuthConfig)); + return Optional.of(existingAuthConfig); + } + } catch (Exception e) { + log.info( + "Failure when attempting to lookup auth config. Please ignore if you don't have images in an authenticated registry. Details: (dockerImageName: {}, configFile: {}, configEnv: {}). Falling back to docker-java default behaviour. Exception message: {}", + dockerImageName, + configFile, + DOCKER_AUTH_ENV_VAR, + e.getMessage() + ); + } + return Optional.empty(); + } + + private JsonNode getDockerAuthConfig() throws Exception { + log.debug( + "RegistryAuthLocator has configFile: {} ({}) configEnv: {} ({}) and commandPathPrefix: {}", + configFile, + configFile.exists() ? "exists" : "does not exist", + DOCKER_AUTH_ENV_VAR, + configEnv != null ? "exists" : "does not exist", + commandPathPrefix + ); + + if (configEnv != null) { + log.debug("RegistryAuthLocator reading from environment variable: {}", DOCKER_AUTH_ENV_VAR); + return OBJECT_MAPPER.readTree(configEnv); + } else if (configFile.exists()) { + log.debug("RegistryAuthLocator reading from configFile: {}", configFile); + return OBJECT_MAPPER.readTree(configFile); + } + + throw new NotFoundException( + "No config supplied. Checked in order: " + + configFile + + " (file not found), " + + DOCKER_AUTH_ENV_VAR + + " (not set)" + ); + } + + private AuthConfig findExistingAuthConfig(final JsonNode config, final String reposName) throws Exception { + final Map.Entry entry = findAuthNode(config, reposName); + + if (entry != null && entry.getValue() != null && entry.getValue().size() > 0) { + final AuthConfig deserializedAuth = OBJECT_MAPPER + .treeToValue(entry.getValue(), AuthConfig.class) + .withRegistryAddress(entry.getKey()); + + if ( + StringUtils.isBlank(deserializedAuth.getUsername()) && + StringUtils.isBlank(deserializedAuth.getPassword()) && + !StringUtils.isBlank(deserializedAuth.getAuth()) + ) { + final String rawAuth = new String(Base64.getDecoder().decode(deserializedAuth.getAuth())); + final String[] splitRawAuth = rawAuth.split(":", 2); + + if (splitRawAuth.length == 2) { + deserializedAuth.withUsername(splitRawAuth[0]); + deserializedAuth.withPassword(splitRawAuth[1]); + } + } + + return deserializedAuth; + } + return null; + } + + private AuthConfig authConfigUsingHelper(final JsonNode config, final String reposName) throws Exception { + final JsonNode credHelpers = config.get("credHelpers"); + if (credHelpers != null && credHelpers.size() > 0) { + final JsonNode helperNode = credHelpers.get(reposName); + if (helperNode != null && helperNode.isTextual()) { + final String helper = helperNode.asText(); + return runCredentialProvider(reposName, helper); + } + } + return null; + } + + private AuthConfig authConfigUsingStore(final JsonNode config, final String reposName) throws Exception { + final JsonNode credsStoreNode = config.get("credsStore"); + if (credsStoreNode != null && !credsStoreNode.isMissingNode() && credsStoreNode.isTextual()) { + final String credsStore = credsStoreNode.asText(); + if (StringUtils.isBlank(credsStore)) { + log.warn("Docker auth config credsStore field will be ignored, because value is blank"); + return null; + } + return runCredentialProvider(reposName, credsStore); + } + return null; + } + + private Map.Entry findAuthNode(final JsonNode config, final String reposName) { + final JsonNode auths = config.get("auths"); + if (auths != null && auths.size() > 0) { + final Iterator> fields = auths.fields(); + while (fields.hasNext()) { + final Map.Entry entry = fields.next(); + if (entry.getKey().endsWith("://" + reposName) || entry.getKey().equals(reposName)) { + return entry; + } + } + } + return null; + } + + private AuthConfig runCredentialProvider(String hostName, String helperOrStoreName) throws Exception { + if (StringUtils.isBlank(hostName)) { + log.debug("There is no point in locating AuthConfig for blank hostName. Returning NULL to allow fallback"); + return null; + } + + final String credentialProgramName = getCredentialProgramName(helperOrStoreName); + final CredentialOutput data; + + log.debug( + "Executing docker credential provider: {} to locate auth config for: {}", + credentialProgramName, + hostName + ); + + try { + data = runCredentialProgram(hostName, credentialProgramName); + if (data.getExitValue() == 1) { + final String responseErrorMsg = data.getStdout(); + + if (!StringUtils.isBlank(responseErrorMsg)) { + String credentialsNotFoundMsg = getGenericCredentialsNotFoundMsg( + responseErrorMsg, + credentialProgramName + ); + if (credentialsNotFoundMsg != null && credentialsNotFoundMsg.equals(responseErrorMsg)) { + log.info( + "Credential helper/store ({}) does not have credentials for {}", + credentialProgramName, + hostName + ); + + return null; + } + + log.debug( + "Failure running docker credential helper/store ({}) with output '{}' and error '{}'", + credentialProgramName, + responseErrorMsg, + data.getStderr() + ); + } else { + log.debug("Failure running docker credential helper/store ({})", credentialProgramName); + } + + throw new InvalidResultException(data.getStdout(), null); + } + } catch (Exception e) { + log.debug("Failure running docker credential helper/store ({})", credentialProgramName); + throw e; + } + + final JsonNode helperResponse = OBJECT_MAPPER.readTree(data.getStdout()); + log.debug("Credential helper/store provided auth config for: {}", hostName); + + final String username = helperResponse.at("/Username").asText(); + final String password = helperResponse.at("/Secret").asText(); + final String serverUrl = helperResponse.at("/ServerURL").asText(); + final AuthConfig authConfig = new AuthConfig().withRegistryAddress(serverUrl.isEmpty() ? hostName : serverUrl); + if ("".equals(username)) { + return authConfig.withIdentityToken(password); + } else { + return authConfig.withUsername(username).withPassword(password); + } + } + + private String getCredentialProgramName(String credHelper) { + return commandPathPrefix + "docker-credential-" + credHelper + commandExtension; + } + + private String effectiveRegistryName(DockerImageName dockerImageName) { + final String registry = dockerImageName.getRegistry(); + if (!StringUtils.isEmpty(registry)) { + return registry; + } + return StringUtils.defaultString( + DockerClientFactory.instance().getInfo().getIndexServerAddress(), + DEFAULT_REGISTRY_NAME + ); + } + + private String getGenericCredentialsNotFoundMsg(String credentialsNotFoundMsg, String credentialHelperName) { + if (!CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE.containsKey(credentialHelperName)) { + CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE.put(credentialHelperName, credentialsNotFoundMsg); + } + return CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE.get(credentialHelperName); + } + + private CredentialOutput runCredentialProgram(String hostName, String credentialHelperName) + throws InterruptedException, TimeoutException, IOException { + String[] command = SystemUtils.IS_OS_WINDOWS + ? new String[] { "cmd", "/c", credentialHelperName, "get" } + : new String[] { credentialHelperName, "get" }; + + StringBuffer stdout = new StringBuffer(); + StringBuffer stderr = new StringBuffer(); + + ProcessResult processResult = new ProcessExecutor() + .command(command) + .redirectInput(new ByteArrayInputStream(hostName.getBytes())) + .redirectOutput( + new LogOutputStream() { + @Override + protected void processLine(String line) { + stdout.append(line).append(System.lineSeparator()); + } + } + ) + .redirectError( + new LogOutputStream() { + @Override + protected void processLine(String line) { + stderr.append(line).append(System.lineSeparator()); + } + } + ) + .timeout(30, TimeUnit.SECONDS) + .execute(); + int exitValue = processResult.getExitValue(); + + return new CredentialOutput(exitValue, stdout.toString(), stderr.toString()); + } + + static class CredentialOutput { + + private final int exitValue; + + private final String stdout; + + private final String stderr; + + public CredentialOutput(int exitValue, String stdout, String stderr) { + this.exitValue = exitValue; + this.stdout = stdout.trim(); + this.stderr = stderr.trim(); + } + + int getExitValue() { + return this.exitValue; + } + + String getStdout() { + return this.stdout; + } + + String getStderr() { + return this.stderr; + } + } +} diff --git a/core/src/main/java/org/testcontainers/utility/ResourceReaper.java b/core/src/main/java/org/testcontainers/utility/ResourceReaper.java index e9f10b76b2c..ccea407ed3b 100644 --- a/core/src/main/java/org/testcontainers/utility/ResourceReaper.java +++ b/core/src/main/java/org/testcontainers/utility/ResourceReaper.java @@ -1,42 +1,95 @@ package org.testcontainers.utility; import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.CreateContainerCmd; import com.github.dockerjava.api.command.InspectContainerResponse; -import com.github.dockerjava.api.exception.DockerException; -import com.github.dockerjava.api.exception.InternalServerErrorException; import com.github.dockerjava.api.exception.NotFoundException; -import com.github.dockerjava.api.model.Container; import com.github.dockerjava.api.model.Network; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Throwables; +import com.google.common.collect.Sets; +import lombok.extern.slf4j.Slf4j; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.AbstractMap.SimpleEntry; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Component that responsible for container removal and automatic cleanup of dead containers at JVM shutdown. */ -public final class ResourceReaper { +@Slf4j +public class ResourceReaper { private static final Logger LOGGER = LoggerFactory.getLogger(ResourceReaper.class); + + private static final Map MARKER_LABELS = Collections.singletonMap( + DockerClientFactory.TESTCONTAINERS_SESSION_ID_LABEL, + DockerClientFactory.SESSION_ID + ); + + static final List>> DEATH_NOTE = new ArrayList<>( + Arrays.asList( + Stream + .concat(DockerClientFactory.DEFAULT_LABELS.entrySet().stream(), MARKER_LABELS.entrySet().stream()) + .>map(it -> new SimpleEntry<>("label", it.getKey() + "=" + it.getValue())) + .collect(Collectors.toList()) + ) + ); + private static ResourceReaper instance; - private final DockerClient dockerClient; + + final DockerClient dockerClient = DockerClientFactory.lazyClient(); + private Map registeredContainers = new ConcurrentHashMap<>(); - private List registeredNetworks = new ArrayList<>(); - private ResourceReaper() { - dockerClient = DockerClientFactory.instance().client(); + private Set registeredNetworks = Sets.newConcurrentHashSet(); - // If the JVM stops without containers being stopped, try and stop the container. - Runtime.getRuntime().addShutdownHook(new Thread(this::performCleanup)); - } + private Set registeredImages = Sets.newConcurrentHashSet(); + + private AtomicBoolean hookIsSet = new AtomicBoolean(false); + + /** + * Internal constructor to avoid custom implementations + */ + ResourceReaper() {} - public synchronized static ResourceReaper instance() { + public static synchronized ResourceReaper instance() { if (instance == null) { - instance = new ResourceReaper(); + boolean useRyuk = !Boolean.parseBoolean(System.getenv("TESTCONTAINERS_RYUK_DISABLED")); + if (useRyuk) { + //noinspection deprecation + instance = new RyukResourceReaper(); + } else { + String ryukDisabledMessage = + "\n" + + "********************************************************************************" + + "\n" + + "Ryuk has been disabled. This can cause unexpected behavior in your environment." + + "\n" + + "********************************************************************************"; + LOGGER.warn(ryukDisabledMessage); + + instance = new JVMHookResourceReaper(); + } } return instance; @@ -44,11 +97,42 @@ public synchronized static ResourceReaper instance() { /** * Perform a cleanup. - * + * @deprecated no longer supported API, use {@link DockerClient} directly */ - public synchronized void performCleanup() { - registeredContainers.forEach(this::stopContainer); + @Deprecated + public void performCleanup() { + registeredContainers.forEach(this::removeContainer); registeredNetworks.forEach(this::removeNetwork); + registeredImages.forEach(this::removeImage); + } + + /** + * Register a filter to be cleaned up. + * + * @param filter the filter + * @deprecated only label filter is supported by the prune API, use {@link #registerLabelsFilterForCleanup(Map)} + */ + @Deprecated + public void registerFilterForCleanup(List> filter) { + synchronized (DEATH_NOTE) { + DEATH_NOTE.add(filter); + DEATH_NOTE.notifyAll(); + } + } + + /** + * Register a label to be cleaned up. + * + * @param labels the filter + */ + public void registerLabelsFilterForCleanup(Map labels) { + registerFilterForCleanup( + labels + .entrySet() + .stream() + .map(it -> new SimpleEntry<>("label", it.getKey() + "=" + it.getValue())) + .collect(Collectors.toList()) + ); } /** @@ -56,8 +140,11 @@ public synchronized void performCleanup() { * * @param containerId the ID of the container * @param imageName the image name of the container (used for logging) + * @deprecated no longer supported API */ + @Deprecated public void registerContainerForCleanup(String containerId, String imageName) { + setHook(); registeredContainers.put(containerId, imageName); } @@ -65,9 +152,11 @@ public void registerContainerForCleanup(String containerId, String imageName) { * Stop a potentially running container and remove it, including associated volumes. * * @param containerId the ID of the container + * @deprecated use {@link DockerClient} directly */ + @Deprecated public void stopAndRemoveContainer(String containerId) { - stopContainer(containerId, registeredContainers.get(containerId)); + removeContainer(containerId, registeredContainers.get(containerId)); registeredContainers.remove(containerId); } @@ -77,23 +166,29 @@ public void stopAndRemoveContainer(String containerId) { * * @param containerId the ID of the container * @param imageName the image name of the container (used for logging) + * @deprecated use {@link DockerClient} directly */ + @Deprecated public void stopAndRemoveContainer(String containerId, String imageName) { - stopContainer(containerId, imageName); + removeContainer(containerId, imageName); registeredContainers.remove(containerId); } - private void stopContainer(String containerId, String imageName) { + private void removeContainer(String containerId, String imageName) { boolean running; try { InspectContainerResponse containerInfo = dockerClient.inspectContainerCmd(containerId).exec(); - running = containerInfo.getState().getRunning(); + running = containerInfo.getState() != null && Boolean.TRUE.equals(containerInfo.getState().getRunning()); } catch (NotFoundException e) { - LOGGER.trace("Was going to stop container but it apparently no longer exists: {}"); + LOGGER.trace("Was going to stop container but it apparently no longer exists: {}", containerId); return; - } catch (DockerException e) { - LOGGER.trace("Error encountered when checking container for shutdown (ID: {}) - it may not have been stopped, or may already be stopped: {}", containerId, e.getMessage()); + } catch (Exception e) { + LOGGER.trace( + "Error encountered when checking container for shutdown (ID: {}) - it may not have been stopped, or may already be stopped. Root cause: {}", + containerId, + Throwables.getRootCause(e).getMessage() + ); return; } @@ -102,65 +197,208 @@ private void stopContainer(String containerId, String imageName) { LOGGER.trace("Stopping container: {}", containerId); dockerClient.killContainerCmd(containerId).exec(); LOGGER.trace("Stopped container: {}", imageName); - } catch (DockerException e) { - LOGGER.trace("Error encountered shutting down container (ID: {}) - it may not have been stopped, or may already be stopped: {}", containerId, e.getMessage()); + } catch (Exception e) { + LOGGER.trace( + "Error encountered shutting down container (ID: {}) - it may not have been stopped, or may already be stopped. Root cause: {}", + containerId, + Throwables.getRootCause(e).getMessage() + ); } } try { dockerClient.inspectContainerCmd(containerId).exec(); - } catch (NotFoundException e) { - LOGGER.trace("Was going to remove container but it apparently no longer exists: {}"); + } catch (Exception e) { + LOGGER.trace("Was going to remove container but it apparently no longer exists: {}", containerId); return; } try { LOGGER.trace("Removing container: {}", containerId); - try { - dockerClient.removeContainerCmd(containerId).withRemoveVolumes(true).withForce(true).exec(); - LOGGER.debug("Removed container and associated volume(s): {}", imageName); - } catch (InternalServerErrorException e) { - LOGGER.trace("Exception when removing container with associated volume(s): {} (due to {})", imageName, e.getMessage()); - } - } catch (DockerException e) { - LOGGER.trace("Error encountered shutting down container (ID: {}) - it may not have been stopped, or may already be stopped: {}", containerId, e.getMessage()); + dockerClient.removeContainerCmd(containerId).withRemoveVolumes(true).withForce(true).exec(); + LOGGER.debug("Removed container and associated volume(s): {}", imageName); + } catch (Exception e) { + LOGGER.trace( + "Error encountered shutting down container (ID: {}) - it may not have been stopped, or may already be stopped. Root cause: {}", + containerId, + Throwables.getRootCause(e).getMessage() + ); } } /** * Register a network to be cleaned up at JVM shutdown. * - * @param networkName the image name of the network + * @param id the ID of the network + * @deprecated no longer supported API */ - public void registerNetworkForCleanup(String networkName) { - registeredNetworks.add(networkName); + @Deprecated + public void registerNetworkIdForCleanup(String id) { + setHook(); + registeredNetworks.add(id); } /** - * Removes any networks that contain the identifier. - * @param identifier + * Removes a network by ID. + * @param id + * @deprecated use {@link DockerClient} directly */ - public void removeNetworks(String identifier) { - removeNetwork(identifier); + @Deprecated + public void removeNetworkById(String id) { + removeNetwork(id); } - private void removeNetwork(String networkName) { - List networks; + private void removeNetwork(String id) { try { - networks = dockerClient.listNetworksCmd().withNameFilter(networkName).exec(); - } catch (DockerException e) { - LOGGER.trace("Error encountered when looking up network for removal (name: {}) - it may not have been removed", networkName); - return; + List networks; + try { + // Try to find the network if it still exists + // Listing by ID first prevents docker-java logging an error if we just go blindly into removeNetworkCmd + networks = dockerClient.listNetworksCmd().withIdFilter(id).exec(); + } catch (Exception e) { + LOGGER.trace( + "Error encountered when looking up network for removal (name: {}) - it may not have been removed", + id + ); + return; + } + + // at this point networks should contain either 0 or 1 entries, depending on whether the network exists + // using a for loop we essentially treat the network like an optional, only applying the removal if it exists + for (Network network : networks) { + try { + dockerClient.removeNetworkCmd(network.getId()).exec(); + registeredNetworks.remove(network.getId()); + LOGGER.debug("Removed network: {}", id); + } catch (Exception e) { + LOGGER.trace( + "Error encountered removing network (name: {}) - it may not have been removed", + network.getName() + ); + } + } + } finally { + registeredNetworks.remove(id); } + } - for (Network network : networks) { - try { - dockerClient.removeNetworkCmd(network.getId()).exec(); - registeredNetworks.remove(network.getId()); - LOGGER.debug("Removed network: {}", networkName); - } catch (DockerException e) { - LOGGER.trace("Error encountered removing network (name: {}) - it may not have been removed", network.getName()); + /** + * @deprecated no longer supported API + */ + @Deprecated + public void unregisterNetwork(String identifier) { + registeredNetworks.remove(identifier); + } + + /** + * @deprecated no longer supported API + */ + @Deprecated + public void unregisterContainer(String identifier) { + registeredContainers.remove(identifier); + } + + /** + * @deprecated no longer supported API + */ + @Deprecated + public void registerImageForCleanup(String dockerImageName) { + setHook(); + registeredImages.add(dockerImageName); + } + + private void removeImage(String dockerImageName) { + LOGGER.trace("Removing image tagged {}", dockerImageName); + try { + dockerClient.removeImageCmd(dockerImageName).withForce(true).exec(); + } catch (Throwable e) { + LOGGER.warn("Unable to delete image " + dockerImageName, e); + } + } + + void setHook() { + if (hookIsSet.compareAndSet(false, true)) { + // If the JVM stops without containers being stopped, try and stop the container. + Runtime + .getRuntime() + .addShutdownHook(new Thread(DockerClientFactory.TESTCONTAINERS_THREAD_GROUP, this::performCleanup)); + } + } + + /** + * + * @deprecated internal API + */ + @Deprecated + public Map getLabels() { + return MARKER_LABELS; + } + + /** + * + * @deprecated internal API + */ + @Deprecated + public CreateContainerCmd register(GenericContainer container, CreateContainerCmd cmd) { + cmd.getLabels().putAll(getLabels()); + return cmd; + } + + /** + * @deprecated internal API + */ + @Deprecated + public void init() {} + + static class FilterRegistry { + + @VisibleForTesting + static final String ACKNOWLEDGMENT = "ACK"; + + private final BufferedReader in; + + private final OutputStream out; + + FilterRegistry(InputStream ryukInputStream, OutputStream ryukOutputStream) { + this.in = new BufferedReader(new InputStreamReader(ryukInputStream)); + this.out = ryukOutputStream; + } + + /** + * Registers the given filters with Ryuk + * + * @param filters the filter to register + * @return true if the filters have been registered successfully, false otherwise + * @throws IOException if communication with Ryuk fails + */ + protected boolean register(List> filters) throws IOException { + String query = filters + .stream() + .map(it -> { + try { + return ( + URLEncoder.encode(it.getKey(), "UTF-8") + "=" + URLEncoder.encode(it.getValue(), "UTF-8") + ); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.joining("&")); + + log.debug("Sending '{}' to Ryuk", query); + out.write(query.getBytes()); + out.write('\n'); + out.flush(); + + return waitForAcknowledgment(in); + } + + private static boolean waitForAcknowledgment(BufferedReader in) throws IOException { + String line = in.readLine(); + while (line != null && !ACKNOWLEDGMENT.equalsIgnoreCase(line)) { + line = in.readLine(); } + return ACKNOWLEDGMENT.equalsIgnoreCase(line); } } } diff --git a/core/src/main/java/org/testcontainers/utility/RyukContainer.java b/core/src/main/java/org/testcontainers/utility/RyukContainer.java new file mode 100644 index 00000000000..a0139420cab --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/RyukContainer.java @@ -0,0 +1,32 @@ +package org.testcontainers.utility; + +import com.github.dockerjava.api.model.Bind; +import com.github.dockerjava.api.model.Volume; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +class RyukContainer extends GenericContainer { + + RyukContainer() { + super("testcontainers/ryuk:0.14.0"); + withExposedPorts(8080); + withCreateContainerCmdModifier(cmd -> { + cmd.withName("testcontainers-ryuk-" + DockerClientFactory.SESSION_ID); + cmd.withHostConfig( + cmd + .getHostConfig() + .withAutoRemove(true) + .withPrivileged(TestcontainersConfiguration.getInstance().isRyukPrivileged()) + .withBinds( + new Bind( + DockerClientFactory.instance().getRemoteDockerUnixSocketPath(), + new Volume("/var/run/docker.sock") + ) + ) + ); + }); + + waitingFor(Wait.forLogMessage(".*Started.*", 1)); + } +} diff --git a/core/src/main/java/org/testcontainers/utility/RyukResourceReaper.java b/core/src/main/java/org/testcontainers/utility/RyukResourceReaper.java new file mode 100644 index 00000000000..999b1cbbffb --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/RyukResourceReaper.java @@ -0,0 +1,135 @@ +package org.testcontainers.utility; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.rnorth.ducttape.ratelimits.RateLimiter; +import org.rnorth.ducttape.ratelimits.RateLimiterBuilder; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Ryuk-based {@link ResourceReaper} implementation. + * + * @see moby-ryuk + */ +@Slf4j +class RyukResourceReaper extends ResourceReaper { + + private static final RateLimiter RYUK_ACK_RATE_LIMITER = RateLimiterBuilder + .newBuilder() + .withRate(4, TimeUnit.SECONDS) + .withConstantThroughput() + .build(); + + private final AtomicBoolean started = new AtomicBoolean(false); + + private final RyukContainer ryukContainer = new RyukContainer(); + + @Override + public void init() { + if (!TestcontainersConfiguration.getInstance().environmentSupportsReuse()) { + log.debug("Ryuk is enabled"); + maybeStart(); + log.info("Ryuk started - will monitor and terminate Testcontainers containers on JVM exit"); + } else { + log.debug("Ryuk is enabled but will be started on demand"); + } + } + + @Override + public void registerLabelsFilterForCleanup(Map labels) { + maybeStart(); + super.registerLabelsFilterForCleanup(labels); + } + + @Override + public Map getLabels() { + maybeStart(); + return super.getLabels(); + } + + @Override + public CreateContainerCmd register(GenericContainer container, CreateContainerCmd cmd) { + if (container == ryukContainer) { + // Do not register Ryuk container to avoid self-pruning + return cmd; + } + + maybeStart(); + return super.register(container, cmd); + } + + @SneakyThrows(InterruptedException.class) + private synchronized void maybeStart() { + if (!started.compareAndSet(false, true)) { + return; + } + + ryukContainer.start(); + + CountDownLatch ryukScheduledLatch = new CountDownLatch(1); + + String host = ryukContainer.getHost(); + Integer ryukPort = ryukContainer.getFirstMappedPort(); + Thread kiraThread = new Thread( + DockerClientFactory.TESTCONTAINERS_THREAD_GROUP, + () -> { + while (true) { + RYUK_ACK_RATE_LIMITER.doWhenReady(() -> { + int index = 0; + // not set the read timeout, as Ryuk would not send anything unless a new filter is submitted, meaning that we would get a timeout exception pretty quick + try (Socket clientSocket = new Socket()) { + clientSocket.connect(new InetSocketAddress(host, ryukPort), 5 * 1000); + ResourceReaper.FilterRegistry registry = new ResourceReaper.FilterRegistry( + clientSocket.getInputStream(), + clientSocket.getOutputStream() + ); + + synchronized (ResourceReaper.DEATH_NOTE) { + while (true) { + if (ResourceReaper.DEATH_NOTE.size() <= index) { + try { + ResourceReaper.DEATH_NOTE.wait(1_000); + continue; + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + List> filters = ResourceReaper.DEATH_NOTE.get(index); + boolean isAcknowledged = registry.register(filters); + if (isAcknowledged) { + log.debug("Received 'ACK' from Ryuk"); + ryukScheduledLatch.countDown(); + index++; + } else { + log.debug("Didn't receive 'ACK' from Ryuk. Will retry to send filters."); + } + } + } + } catch (IOException e) { + log.warn("Can not connect to Ryuk at {}:{}", host, ryukPort, e); + } + }); + } + }, + "testcontainers-ryuk" + ); + kiraThread.setDaemon(true); + kiraThread.start(); + // We need to wait before we can start any containers to make sure that we delete them + if (!ryukScheduledLatch.await(TestcontainersConfiguration.getInstance().getRyukTimeout(), TimeUnit.SECONDS)) { + log.error("Timed out waiting for Ryuk container to start. Ryuk's logs:\n{}", ryukContainer.getLogs()); + throw new IllegalStateException(String.format("Could not connect to Ryuk at %s:%s", host, ryukPort)); + } + } +} diff --git a/core/src/main/java/org/testcontainers/utility/TestEnvironment.java b/core/src/main/java/org/testcontainers/utility/TestEnvironment.java index dbd8e45a98f..ee58199540b 100644 --- a/core/src/main/java/org/testcontainers/utility/TestEnvironment.java +++ b/core/src/main/java/org/testcontainers/utility/TestEnvironment.java @@ -8,8 +8,7 @@ */ public class TestEnvironment { - private TestEnvironment() { - } + private TestEnvironment() {} public static boolean dockerApiAtLeast(String minimumVersion) { ComparableVersion min = new ComparableVersion(minimumVersion); @@ -29,4 +28,3 @@ public static boolean dockerIsDockerMachine() { return DockerClientFactory.instance().isUsing(DockerMachineClientProviderStrategy.class); } } - diff --git a/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java b/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java index c969b08d4f6..5c80b82ba9c 100644 --- a/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java +++ b/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java @@ -1,57 +1,409 @@ package org.testcontainers.utility; -import com.google.common.base.MoreObjects; -import lombok.AccessLevel; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableMap; import lombok.Data; import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.Synchronized; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.testcontainers.UnstableAPI; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.net.MalformedURLException; import java.net.URL; +import java.util.Map; +import java.util.Optional; import java.util.Properties; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; /** - * Provides a mechanism for fetching configuration/defaults from the classpath. + * Provides a mechanism for fetching configuration/default settings. + *

+ * Configuration may be provided in: + *

    + *
  • A file in the user's home directory named .testcontainers.properties
  • + *
  • A file in the classpath named testcontainers.properties
  • + *
  • Environment variables
  • + *
+ *

+ * Note that, if using environment variables, property names are in upper case separated by underscores, preceded by + * TESTCONTAINERS_. */ @Data -@Slf4j @NoArgsConstructor(access = AccessLevel.PRIVATE) +@Slf4j public class TestcontainersConfiguration { + private static String PROPERTIES_FILE_NAME = "testcontainers.properties"; + + private static File USER_CONFIG_FILE = new File(System.getProperty("user.home"), "." + PROPERTIES_FILE_NAME); + + private static final String AMBASSADOR_IMAGE = "richnorth/ambassador"; + + private static final String SOCAT_IMAGE = "alpine/socat"; + + private static final String VNC_RECORDER_IMAGE = "testcontainers/vnc-recorder"; + + private static final String COMPOSE_IMAGE = "docker/compose"; + + private static final String ALPINE_IMAGE = "alpine"; + + private static final String RYUK_IMAGE = "testcontainers/ryuk"; + + private static final String KAFKA_IMAGE = "confluentinc/cp-kafka"; + + private static final String PULSAR_IMAGE = "apachepulsar/pulsar"; + + private static final String LOCALSTACK_IMAGE = "localstack/localstack"; + + private static final String SSHD_IMAGE = "testcontainers/sshd"; + + private static final String ORACLE_IMAGE = "gvenzl/oracle-xe"; + + private static final ImmutableMap CONTAINER_MAPPING = ImmutableMap + .builder() + .put(DockerImageName.parse(AMBASSADOR_IMAGE), "ambassador.container.image") + .put(DockerImageName.parse(SOCAT_IMAGE), "socat.container.image") + .put(DockerImageName.parse(VNC_RECORDER_IMAGE), "vncrecorder.container.image") + .put(DockerImageName.parse(COMPOSE_IMAGE), "compose.container.image") + .put(DockerImageName.parse(ALPINE_IMAGE), "tinyimage.container.image") + .put(DockerImageName.parse(RYUK_IMAGE), "ryuk.container.image") + .put(DockerImageName.parse(KAFKA_IMAGE), "kafka.container.image") + .put(DockerImageName.parse(PULSAR_IMAGE), "pulsar.container.image") + .put(DockerImageName.parse(LOCALSTACK_IMAGE), "localstack.container.image") + .put(DockerImageName.parse(SSHD_IMAGE), "sshd.container.image") + .put(DockerImageName.parse(ORACLE_IMAGE), "oracle.container.image") + .build(); + @Getter(lazy = true) private static final TestcontainersConfiguration instance = loadConfiguration(); - private String ambassadorContainerImage = "richnorth/ambassador:latest"; - private String vncRecordedContainerImage = "richnorth/vnc-recorder:latest"; - private String tinyImage = "alpine:3.2"; + @SuppressWarnings({ "ConstantConditions", "unchecked", "rawtypes" }) + @VisibleForTesting + static AtomicReference getInstanceField() { + // Lazy Getter from Lombok changes the field's type to AtomicReference + return (AtomicReference) (Object) instance; + } - private static TestcontainersConfiguration loadConfiguration() { - final TestcontainersConfiguration config = new TestcontainersConfiguration(); + private final Properties userProperties; + + private final Properties classpathProperties; + + private final Map environment; + + TestcontainersConfiguration( + Properties userProperties, + Properties classpathProperties, + final Map environment + ) { + this.userProperties = userProperties; + this.classpathProperties = classpathProperties; + this.environment = environment; + } - ClassLoader loader = MoreObjects.firstNonNull( - Thread.currentThread().getContextClassLoader(), - TestcontainersConfiguration.class.getClassLoader()); - final URL configOverrides = loader.getResource("testcontainers.properties"); - if (configOverrides != null) { + @Deprecated + public String getAmbassadorContainerImage() { + return getImage(AMBASSADOR_IMAGE).asCanonicalNameString(); + } - log.debug("Testcontainers configuration overrides will be loaded from {}", configOverrides); + @Deprecated + public String getSocatContainerImage() { + return getImage(SOCAT_IMAGE).asCanonicalNameString(); + } - final Properties properties = new Properties(); - try (final InputStream inputStream = configOverrides.openStream()) { - properties.load(inputStream); + @Deprecated + public String getVncRecordedContainerImage() { + return getImage(VNC_RECORDER_IMAGE).asCanonicalNameString(); + } - config.ambassadorContainerImage = properties.getProperty("ambassador.container.image", config.ambassadorContainerImage); - config.vncRecordedContainerImage = properties.getProperty("vncrecorder.container.image", config.vncRecordedContainerImage); - config.tinyImage = properties.getProperty("tinyimage.container.image", config.tinyImage); + @Deprecated + public String getDockerComposeContainerImage() { + return getImage(COMPOSE_IMAGE).asCanonicalNameString(); + } - log.debug("Testcontainers configuration overrides loaded from {}: {}", configOverrides, config); + @Deprecated + public String getTinyImage() { + return getImage(ALPINE_IMAGE).asCanonicalNameString(); + } - } catch (IOException e) { - log.error("Testcontainers config override was found on classpath but could not be loaded", e); + public boolean isRyukPrivileged() { + return Boolean.parseBoolean(getEnvVarOrProperty("ryuk.container.privileged", "true")); + } + + @Deprecated + public String getRyukImage() { + return getImage(RYUK_IMAGE).asCanonicalNameString(); + } + + @Deprecated + public String getSSHdImage() { + return getImage(SSHD_IMAGE).asCanonicalNameString(); + } + + public Integer getRyukTimeout() { + return Integer.parseInt(getEnvVarOrProperty("ryuk.container.timeout", "30")); + } + + @Deprecated + public String getKafkaImage() { + return getImage(KAFKA_IMAGE).asCanonicalNameString(); + } + + @Deprecated + public String getOracleImage() { + return getImage(ORACLE_IMAGE).asCanonicalNameString(); + } + + @Deprecated + public String getPulsarImage() { + return getImage(PULSAR_IMAGE).asCanonicalNameString(); + } + + @Deprecated + public String getLocalStackImage() { + return getImage(LOCALSTACK_IMAGE).asCanonicalNameString(); + } + + public boolean isDisableChecks() { + return Boolean.parseBoolean(getEnvVarOrUserProperty("checks.disable", "false")); + } + + @UnstableAPI + public boolean environmentSupportsReuse() { + return Boolean.parseBoolean(getEnvVarOrUserProperty("testcontainers.reuse.enable", "false")); + } + + public String getDockerClientStrategyClassName() { + // getConfigurable won't apply the TESTCONTAINERS_ prefix when looking for env vars if DOCKER_ appears at the beginning. + // Because of this overlap, and the desire to not change this specific TESTCONTAINERS_DOCKER_CLIENT_STRATEGY setting, + // we special-case the logic here so that docker.client.strategy is used when reading properties files and + // TESTCONTAINERS_DOCKER_CLIENT_STRATEGY is used when searching environment variables. + + // looks for TESTCONTAINERS_ prefixed env var only + String prefixedEnvVarStrategy = environment.get("TESTCONTAINERS_DOCKER_CLIENT_STRATEGY"); + if (prefixedEnvVarStrategy != null) { + return prefixedEnvVarStrategy; + } + + // looks for unprefixed env var or unprefixed property, or null if the strategy is not set at all + return getEnvVarOrUserProperty("docker.client.strategy", null); + } + + public String getTransportType() { + return getEnvVarOrProperty("transport.type", "httpclient5"); + } + + public Integer getImagePullPauseTimeout() { + return Integer.parseInt(getEnvVarOrProperty("pull.pause.timeout", "30")); + } + + public Integer getImagePullTimeout() { + return Integer.parseInt(getEnvVarOrProperty("pull.timeout", "120")); + } + + public String getImageSubstitutorClassName() { + return getEnvVarOrProperty("image.substitutor", null); + } + + public String getImagePullPolicy() { + return getEnvVarOrProperty("pull.policy", null); + } + + public Integer getClientPingTimeout() { + return Integer.parseInt(getEnvVarOrProperty("client.ping.timeout", "10")); + } + + @Nullable + @Contract("_, !null, _ -> !null") + private String getConfigurable( + @NotNull final String propertyName, + @Nullable final String defaultValue, + Properties... propertiesSources + ) { + String envVarName = propertyName.replaceAll("\\.", "_").toUpperCase(); + if (!envVarName.startsWith("TESTCONTAINERS_") && !envVarName.startsWith("DOCKER_")) { + envVarName = "TESTCONTAINERS_" + envVarName; + } + + if (environment.containsKey(envVarName)) { + String value = environment.get(envVarName); + if (!value.isEmpty()) { + return value; + } + } + + for (final Properties properties : propertiesSources) { + if (properties.get(propertyName) != null) { + return (String) properties.get(propertyName); + } + } + + return defaultValue; + } + + /** + * Gets a configured setting from an environment variable (if present) or a configuration file property otherwise. + * The configuration file will be the .testcontainers.properties file in the user's home directory or + * a testcontainers.properties found on the classpath. + *

+ * Note that when searching environment variables, the prefix `TESTCONTAINERS_` will usually be applied to the + * property name, which will be converted to upper-case with underscore separators. This prefix will not be added + * if the property name begins `docker.`. + * + * @param propertyName name of configuration file property (dot-separated lower case) + * @return the found value, or null if not set + */ + @Contract("_, !null -> !null") + public String getEnvVarOrProperty(@NotNull final String propertyName, @Nullable final String defaultValue) { + return getConfigurable(propertyName, defaultValue, userProperties, classpathProperties); + } + + /** + * Gets a configured setting from an environment variable (if present) or a configuration file property otherwise. + * The configuration file will be the .testcontainers.properties file in the user's home directory. + *

+ * Note that when searching environment variables, the prefix `TESTCONTAINERS_` will usually be applied to the + * property name, which will be converted to upper-case with underscore separators. This prefix will not be added + * if the property name begins `docker.`. + * + * @param propertyName name of configuration file property (dot-separated lower case) + * @return the found value, or null if not set + */ + @Contract("_, !null -> !null") + public String getEnvVarOrUserProperty(@NotNull final String propertyName, @Nullable final String defaultValue) { + return getConfigurable(propertyName, defaultValue, userProperties); + } + + /** + * Gets a configured setting from ~/.testcontainers.properties. + * + * @param propertyName name of configuration file property (dot-separated lower case) + * @return the found value, or null if not set + */ + @Contract("_, !null -> !null") + public String getUserProperty(@NotNull final String propertyName, @Nullable final String defaultValue) { + return this.userProperties.get(propertyName) != null + ? (String) this.userProperties.get(propertyName) + : defaultValue; + } + + /** + * @return properties values available from user properties and classpath properties. Values set by environment + * variable are NOT included. + * @deprecated usages should be removed ASAP. See {@link TestcontainersConfiguration#getEnvVarOrProperty(String, String)}, + * {@link TestcontainersConfiguration#getEnvVarOrUserProperty(String, String)} or {@link TestcontainersConfiguration#getUserProperty(String, String)} + * for suitable replacements. + */ + @Deprecated + public Properties getProperties() { + return Stream + .of(userProperties, classpathProperties) + .reduce( + new Properties(), + (a, b) -> { + a.putAll(b); + return a; + } + ); + } + + @Deprecated + public boolean updateGlobalConfig(@NonNull String prop, @NonNull String value) { + return updateUserConfig(prop, value); + } + + @Synchronized + public boolean updateUserConfig(@NonNull String prop, @NonNull String value) { + try { + if (value.equals(userProperties.get(prop))) { + return false; + } + + userProperties.setProperty(prop, value); + + USER_CONFIG_FILE.createNewFile(); + try (OutputStream outputStream = new FileOutputStream(USER_CONFIG_FILE)) { + userProperties.store(outputStream, "Modified by Testcontainers"); } + + // Update internal state only if environment config was successfully updated + userProperties.setProperty(prop, value); + return true; + } catch (Exception e) { + log.debug("Can't store environment property {} in {}", prop, USER_CONFIG_FILE); + return false; + } + } + + @SneakyThrows(MalformedURLException.class) + private static TestcontainersConfiguration loadConfiguration() { + return new TestcontainersConfiguration( + readProperties(USER_CONFIG_FILE.toURI().toURL()), + ClasspathScanner + .scanFor(PROPERTIES_FILE_NAME) + .map(TestcontainersConfiguration::readProperties) + .reduce( + new Properties(), + (a, b) -> { + // first-write-wins merging - URLs appearing first on the classpath alphabetically will take priority. + // Note that this means that file: URLs will always take priority over jar: URLs. + b.putAll(a); + return b; + } + ), + System.getenv() + ); + } + + private static Properties readProperties(URL url) { + log.debug("Testcontainers configuration overrides will be loaded from {}", url); + Properties properties = new Properties(); + try (InputStream inputStream = url.openStream()) { + properties.load(inputStream); + } catch (FileNotFoundException e) { + log.debug( + "Attempted to read Testcontainers configuration file at {} but the file was not found. Exception message: {}", + url, + ExceptionUtils.getRootCauseMessage(e) + ); + } catch (IOException e) { + log.debug( + "Attempted to read Testcontainers configuration file at {} but could it not be loaded. Exception message: {}", + url, + ExceptionUtils.getRootCauseMessage(e) + ); } + return properties; + } + + private DockerImageName getImage(final String defaultValue) { + return getConfiguredSubstituteImage(DockerImageName.parse(defaultValue)); + } - return config; + DockerImageName getConfiguredSubstituteImage(DockerImageName original) { + for (final Map.Entry entry : CONTAINER_MAPPING.entrySet()) { + if (original.isCompatibleWith(entry.getKey())) { + return Optional + .ofNullable(entry.getValue()) + .map(propertyName -> getEnvVarOrProperty(propertyName, null)) + .map(String::valueOf) + .map(String::trim) + .map(DockerImageName::parse) + .orElse(original) + .asCompatibleSubstituteFor(original); + } + } + return original; } } diff --git a/core/src/main/java/org/testcontainers/utility/ThrowingFunction.java b/core/src/main/java/org/testcontainers/utility/ThrowingFunction.java new file mode 100644 index 00000000000..a8138f9dda0 --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/ThrowingFunction.java @@ -0,0 +1,5 @@ +package org.testcontainers.utility; + +public interface ThrowingFunction { + R apply(T t) throws Exception; +} diff --git a/core/src/main/java/org/testcontainers/utility/Versioning.java b/core/src/main/java/org/testcontainers/utility/Versioning.java new file mode 100644 index 00000000000..707c2b91820 --- /dev/null +++ b/core/src/main/java/org/testcontainers/utility/Versioning.java @@ -0,0 +1,115 @@ +package org.testcontainers.utility; + +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * Represents mechanisms for versioning docker images. + */ +interface Versioning { + AnyVersion ANY = new AnyVersion(); + + boolean isValid(); + + String getSeparator(); + + @NoArgsConstructor(access = AccessLevel.PRIVATE) + class AnyVersion implements Versioning { + + @Override + public boolean isValid() { + return true; + } + + @Override + public String getSeparator() { + return ":"; + } + + @Override + public String toString() { + return "latest"; + } + + @Override + public boolean equals(final Object obj) { + return obj instanceof Versioning; + } + + @Override + public int hashCode() { + return super.hashCode(); + } + } + + @EqualsAndHashCode + class TagVersioning implements Versioning { + + public static final String TAG_REGEX = "[\\w][\\w.\\-]{0,127}"; + + static final TagVersioning LATEST = new TagVersioning("latest"); + + private final String tag; + + TagVersioning(String tag) { + this.tag = tag; + } + + @Override + public boolean isValid() { + return tag.matches(TAG_REGEX); + } + + @Override + public String getSeparator() { + return ":"; + } + + @Override + public String toString() { + return tag; + } + } + + @EqualsAndHashCode + class Sha256Versioning implements Versioning { + + public static final String HASH_REGEX = "[0-9a-fA-F]{32,}"; + + private final String hash; + + private final String tag; + + Sha256Versioning(String hash) { + this(hash, null); + } + + Sha256Versioning(String hash, String tag) { + this.hash = hash; + this.tag = tag; + } + + String getTag() { + return tag; + } + + @Override + public boolean isValid() { + if (tag != null && !tag.matches(TagVersioning.TAG_REGEX)) { + return false; + } + return hash.matches(HASH_REGEX); + } + + @Override + public String getSeparator() { + return "@"; + } + + @Override + public String toString() { + return "sha256:" + hash; + } + } +} diff --git a/core/src/main/resources/META-INF/native-image/org.testcontainers/testcontainers/native-image.properties b/core/src/main/resources/META-INF/native-image/org.testcontainers/testcontainers/native-image.properties new file mode 100644 index 00000000000..c53eebbb546 --- /dev/null +++ b/core/src/main/resources/META-INF/native-image/org.testcontainers/testcontainers/native-image.properties @@ -0,0 +1 @@ +Args = --enable-http --enable-https diff --git a/core/src/main/resources/META-INF/services/README.md b/core/src/main/resources/META-INF/services/README.md deleted file mode 100644 index fd11f50456e..00000000000 --- a/core/src/main/resources/META-INF/services/README.md +++ /dev/null @@ -1 +0,0 @@ -The contents of this folder is a workaround for issues described here: https://github.com/KostyaSha/docker-java-shade/issues/1 \ No newline at end of file diff --git a/core/src/main/resources/META-INF/services/org.testcontainers.dockerclient.DockerClientProviderStrategy b/core/src/main/resources/META-INF/services/org.testcontainers.dockerclient.DockerClientProviderStrategy new file mode 100644 index 00000000000..cae8bf53041 --- /dev/null +++ b/core/src/main/resources/META-INF/services/org.testcontainers.dockerclient.DockerClientProviderStrategy @@ -0,0 +1,7 @@ +org.testcontainers.dockerclient.TestcontainersHostPropertyClientProviderStrategy +org.testcontainers.dockerclient.EnvironmentAndSystemPropertyClientProviderStrategy +org.testcontainers.dockerclient.UnixSocketClientProviderStrategy +org.testcontainers.dockerclient.DockerMachineClientProviderStrategy +org.testcontainers.dockerclient.NpipeSocketClientProviderStrategy +org.testcontainers.dockerclient.RootlessDockerClientProviderStrategy +org.testcontainers.dockerclient.DockerDesktopClientProviderStrategy diff --git a/core/src/main/resources/META-INF/services/org.testcontainers.shaded.com.fasterxml.jackson.core.JsonFactory b/core/src/main/resources/META-INF/services/org.testcontainers.shaded.com.fasterxml.jackson.core.JsonFactory deleted file mode 100755 index 239a78a0a82..00000000000 --- a/core/src/main/resources/META-INF/services/org.testcontainers.shaded.com.fasterxml.jackson.core.JsonFactory +++ /dev/null @@ -1 +0,0 @@ -com.fasterxml.jackson.core.JsonFactory diff --git a/core/src/main/resources/META-INF/services/org.testcontainers.shaded.com.fasterxml.jackson.core.ObjectCodec b/core/src/main/resources/META-INF/services/org.testcontainers.shaded.com.fasterxml.jackson.core.ObjectCodec deleted file mode 100755 index f126bb401c2..00000000000 --- a/core/src/main/resources/META-INF/services/org.testcontainers.shaded.com.fasterxml.jackson.core.ObjectCodec +++ /dev/null @@ -1 +0,0 @@ -com.fasterxml.jackson.databind.ObjectMapper diff --git a/core/src/main/resources/META-INF/services/org.testcontainers.shaded.com.github.dockerjava.api.command.DockerCmdExecFactory b/core/src/main/resources/META-INF/services/org.testcontainers.shaded.com.github.dockerjava.api.command.DockerCmdExecFactory deleted file mode 100644 index f0686bc9f31..00000000000 --- a/core/src/main/resources/META-INF/services/org.testcontainers.shaded.com.github.dockerjava.api.command.DockerCmdExecFactory +++ /dev/null @@ -1 +0,0 @@ -com.github.dockerjava.jaxrs.DockerCmdExecFactoryImpl \ No newline at end of file diff --git a/core/src/main/resources/META-INF/services/org.testcontainers.shaded.javax.ws.rs.ext.RuntimeDelegate b/core/src/main/resources/META-INF/services/org.testcontainers.shaded.javax.ws.rs.ext.RuntimeDelegate deleted file mode 100644 index 550198cfdf7..00000000000 --- a/core/src/main/resources/META-INF/services/org.testcontainers.shaded.javax.ws.rs.ext.RuntimeDelegate +++ /dev/null @@ -1 +0,0 @@ -org.glassfish.jersey.internal.RuntimeDelegateImpl \ No newline at end of file diff --git a/core/src/main/resources/META-INF/services/org.testcontainers.shaded.org.glassfish.hk2.extension.ServiceLocatorGenerator b/core/src/main/resources/META-INF/services/org.testcontainers.shaded.org.glassfish.hk2.extension.ServiceLocatorGenerator deleted file mode 100644 index 7834b52dd6d..00000000000 --- a/core/src/main/resources/META-INF/services/org.testcontainers.shaded.org.glassfish.hk2.extension.ServiceLocatorGenerator +++ /dev/null @@ -1 +0,0 @@ -org.jvnet.hk2.external.generator.ServiceLocatorGeneratorImpl diff --git a/core/src/main/resources/META-INF/services/org.testcontainers.shaded.org.jvnet.hk2.external.generator.ServiceLocatorGenerator b/core/src/main/resources/META-INF/services/org.testcontainers.shaded.org.jvnet.hk2.external.generator.ServiceLocatorGenerator deleted file mode 100644 index 25f6b0c3fc9..00000000000 --- a/core/src/main/resources/META-INF/services/org.testcontainers.shaded.org.jvnet.hk2.external.generator.ServiceLocatorGenerator +++ /dev/null @@ -1 +0,0 @@ -org.testcontainers.shaded.org.jvnet.hk2.external.generator.ServiceLocatorGeneratorImpl diff --git a/core/src/test/java/alt/testcontainers/README.md b/core/src/test/java/alt/testcontainers/README.md new file mode 100644 index 00000000000..0a08ff91ba5 --- /dev/null +++ b/core/src/test/java/alt/testcontainers/README.md @@ -0,0 +1 @@ +Useful place for running tests that need to be outside of the `org.testcontainers` package. diff --git a/core/src/test/java/alt/testcontainers/images/OutOfPackageImagePullPolicyTest.java b/core/src/test/java/alt/testcontainers/images/OutOfPackageImagePullPolicyTest.java new file mode 100644 index 00000000000..5b13d172386 --- /dev/null +++ b/core/src/test/java/alt/testcontainers/images/OutOfPackageImagePullPolicyTest.java @@ -0,0 +1,30 @@ +package alt.testcontainers.images; + +import org.junit.jupiter.api.Test; +import org.testcontainers.TestImages; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; +import org.testcontainers.images.AbstractImagePullPolicy; +import org.testcontainers.images.ImageData; +import org.testcontainers.utility.DockerImageName; + +class OutOfPackageImagePullPolicyTest { + + @Test + void shouldSupportCustomPoliciesOutOfTestcontainersPackage() { + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withImagePullPolicy( + new AbstractImagePullPolicy() { + @Override + protected boolean shouldPullCached(DockerImageName imageName, ImageData localImageData) { + return false; + } + } + ) + ) { + container.withStartupCheckStrategy(new OneShotStartupCheckStrategy()); + container.start(); + } + } +} diff --git a/core/src/test/java/org/testcontainers/DaemonTest.java b/core/src/test/java/org/testcontainers/DaemonTest.java new file mode 100644 index 00000000000..01f4a1c5475 --- /dev/null +++ b/core/src/test/java/org/testcontainers/DaemonTest.java @@ -0,0 +1,60 @@ +package org.testcontainers; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; + +import java.io.File; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * This test forks a new JVM, otherwise it's not possible to reliably diff the threads + */ +class DaemonTest { + + public static void main(String[] args) { + Thread mainThread = Thread.currentThread(); + + GenericContainer genericContainer = null; + + try { + genericContainer = new GenericContainer<>(TestImages.TINY_IMAGE).withCommand("top"); + genericContainer.start(); + + Set threads = new HashSet<>(Thread.getAllStackTraces().keySet()); + threads.remove(mainThread); + + Set nonDaemonThreads = threads.stream().filter(it -> !it.isDaemon()).collect(Collectors.toSet()); + + if (!nonDaemonThreads.isEmpty()) { + String nonDaemonThreadNames = nonDaemonThreads + .stream() + .map(Thread::getName) + .collect(Collectors.joining("\n", "\n", "")); + + fail("Expected all threads to be daemons but the following are not:\n" + nonDaemonThreadNames); + } + } finally { + if (genericContainer != null) { + genericContainer.stop(); + } + } + } + + @Test + void testThatAllThreadsAreDaemons() throws Exception { + ProcessBuilder processBuilder = new ProcessBuilder( + new File(System.getProperty("java.home")).toPath().resolve("bin").resolve("java").toString(), + "-ea", + "-classpath", + System.getProperty("java.class.path"), + DaemonTest.class.getCanonicalName() + ); + + assertThat(processBuilder.inheritIO().start().waitFor()).isZero(); + } +} diff --git a/core/src/test/java/org/testcontainers/DockerClientFactoryTest.java b/core/src/test/java/org/testcontainers/DockerClientFactoryTest.java new file mode 100644 index 00000000000..56c68d4fd5d --- /dev/null +++ b/core/src/test/java/org/testcontainers/DockerClientFactoryTest.java @@ -0,0 +1,61 @@ +package org.testcontainers; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.dockerclient.LogToStringContainerCallback; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MockTestcontainersConfigurationExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Test for {@link DockerClientFactory}. + */ +@ExtendWith(MockTestcontainersConfigurationExtension.class) +class DockerClientFactoryTest { + + @Test + void runCommandInsideDockerShouldNotFailIfImageDoesNotExistsLocally() { + try (DockerRegistryContainer registryContainer = new DockerRegistryContainer()) { + registryContainer.start(); + DockerImageName imageName = registryContainer.createImage(); + + DockerClientFactory dockFactory = DockerClientFactory.instance(); + + dockFactory.runInsideDocker( + imageName, + cmd -> cmd.withCmd("sh", "-c", "echo 'SUCCESS'"), + (client, id) -> { + return client + .logContainerCmd(id) + .withStdOut(true) + .exec(new LogToStringContainerCallback()) + .toString(); + } + ); + } + } + + @Test + void dockerHostIpAddress() { + DockerClientFactory instance = new DockerClientFactory(); + instance.strategy = null; + assertThat(instance.dockerHostIpAddress()).isNotNull(); + } + + @Test + void failedChecksFailFast() { + DockerClientFactory instance = DockerClientFactory.instance(); + assertThat(instance.client()).isNotNull(); + assertThat(instance.cachedClientFailure).isNull(); + try { + RuntimeException failure = new IllegalStateException("Boom!"); + instance.cachedClientFailure = failure; + // Fail fast + assertThatThrownBy(instance::client).isEqualTo(failure); + } finally { + instance.cachedClientFailure = null; + } + } +} diff --git a/core/src/test/java/org/testcontainers/DockerRegistryContainer.java b/core/src/test/java/org/testcontainers/DockerRegistryContainer.java new file mode 100644 index 00000000000..956bb9fff9e --- /dev/null +++ b/core/src/test/java/org/testcontainers/DockerRegistryContainer.java @@ -0,0 +1,115 @@ +package org.testcontainers; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.async.ResultCallback; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.command.PullImageResultCallback; +import lombok.Getter; +import lombok.NonNull; +import lombok.SneakyThrows; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.FrameConsumerResultCallback; +import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.containers.output.WaitingConsumer; +import org.testcontainers.utility.Base58; +import org.testcontainers.utility.DockerImageName; + +import java.util.UUID; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class DockerRegistryContainer extends GenericContainer { + + @Getter + String endpoint; + + public DockerRegistryContainer() { + super(TestImages.DOCKER_REGISTRY_IMAGE); + } + + public DockerRegistryContainer(@NonNull Future image) { + super(image); + } + + @Override + protected void configure() { + super.configure(); + withEnv("REGISTRY_HTTP_ADDR", "127.0.0.1:0"); + withCreateContainerCmdModifier(cmd -> { + cmd.getHostConfig().withNetworkMode("host"); + }); + } + + @Override + @SneakyThrows + protected void containerIsStarting(InspectContainerResponse containerInfo) { + AtomicInteger port = new AtomicInteger(-1); + try (FrameConsumerResultCallback resultCallback = new FrameConsumerResultCallback()) { + WaitingConsumer waitingConsumer = new WaitingConsumer(); + resultCallback.addConsumer(OutputFrame.OutputType.STDERR, waitingConsumer); + + dockerClient + .logContainerCmd(containerInfo.getId()) + .withStdErr(true) + .withFollowStream(true) + .exec(resultCallback); + + Pattern pattern = Pattern.compile( + ".*listening on .*:(\\d+).*", + Pattern.DOTALL | Pattern.CASE_INSENSITIVE | Pattern.MULTILINE + ); + waitingConsumer.waitUntil( + it -> { + String s = it.getUtf8String(); + Matcher matcher = pattern.matcher(s); + if (matcher.matches()) { + port.set(Integer.parseInt(matcher.group(1))); + return true; + } else { + return false; + } + }, + 10, + TimeUnit.SECONDS + ); + } + + endpoint = getHost() + ":" + port.get(); + } + + public DockerImageName createImage() { + return createImage(UUID.randomUUID().toString()); + } + + public DockerImageName createImage(String tag) { + return createImage("testcontainers/helloworld:latest", tag); + } + + @SneakyThrows(InterruptedException.class) + public DockerImageName createImage(String originalImage, String tag) { + DockerClient client = getDockerClient(); + client.pullImageCmd(originalImage).exec(new PullImageResultCallback()).awaitCompletion(); + + String dummyImageId = client.inspectImageCmd(originalImage).exec().getId(); + + DockerImageName imageName = DockerImageName + .parse(getEndpoint() + "/" + Base58.randomString(6).toLowerCase()) + .withTag(tag); + + // push the image to the registry + client.tagImageCmd(dummyImageId, imageName.getUnversionedPart(), tag).exec(); + + client + .pushImageCmd(imageName.asCanonicalNameString()) + .exec(new ResultCallback.Adapter<>()) + .awaitCompletion(1, TimeUnit.MINUTES); + + // Remove from local cache, tests should pull the image themselves + client.removeImageCmd(imageName.asCanonicalNameString()).exec(); + + return imageName; + } +} diff --git a/core/src/test/java/org/testcontainers/TestImages.java b/core/src/test/java/org/testcontainers/TestImages.java new file mode 100644 index 00000000000..0d052f8e963 --- /dev/null +++ b/core/src/test/java/org/testcontainers/TestImages.java @@ -0,0 +1,17 @@ +package org.testcontainers; + +import org.testcontainers.utility.DockerImageName; + +public interface TestImages { + DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:6-alpine"); + + DockerImageName RABBITMQ_IMAGE = DockerImageName.parse("rabbitmq:3.7.25"); + + DockerImageName MONGODB_IMAGE = DockerImageName.parse("mongo:4.4"); + + DockerImageName ALPINE_IMAGE = DockerImageName.parse("alpine:3.17"); + + DockerImageName DOCKER_REGISTRY_IMAGE = DockerImageName.parse("registry:2.7.0"); + + DockerImageName TINY_IMAGE = DockerImageName.parse("alpine:3.17"); +} diff --git a/core/src/test/java/org/testcontainers/containers/ComposeContainerTest.java b/core/src/test/java/org/testcontainers/containers/ComposeContainerTest.java new file mode 100644 index 00000000000..cee985d36df --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/ComposeContainerTest.java @@ -0,0 +1,49 @@ +package org.testcontainers.containers; + +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class ComposeContainerTest { + + public static final String DOCKER_IMAGE = "docker:25.0.2"; + + private static final String COMPOSE_FILE_PATH = "src/test/resources/v2-compose-test.yml"; + + @Test + void testWithCustomDockerImage() { + ComposeContainer composeContainer = new ComposeContainer( + DockerImageName.parse(DOCKER_IMAGE), + new File(COMPOSE_FILE_PATH) + ); + composeContainer.start(); + verifyContainerCreation(composeContainer); + composeContainer.stop(); + } + + @Test + void testWithCustomDockerImageAndIdentifier() { + ComposeContainer composeContainer = new ComposeContainer( + DockerImageName.parse(DOCKER_IMAGE), + "myidentifier", + new File(COMPOSE_FILE_PATH) + ); + composeContainer.start(); + verifyContainerCreation(composeContainer); + composeContainer.stop(); + } + + private void verifyContainerCreation(ComposeContainer composeContainer) { + Optional redis = composeContainer.getContainerByServiceName("redis"); + assertThat(redis) + .hasValueSatisfying(container -> { + assertThat(container.isRunning()).isTrue(); + assertThat(container.getContainerInfo().getConfig().getLabels()) + .containsEntry("com.docker.compose.version", "2.24.5"); + }); + } +} diff --git a/core/src/test/java/org/testcontainers/containers/ComposeContainerWithServicesTest.java b/core/src/test/java/org/testcontainers/containers/ComposeContainerWithServicesTest.java new file mode 100644 index 00000000000..0e921316bf3 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/ComposeContainerWithServicesTest.java @@ -0,0 +1,183 @@ +package org.testcontainers.containers; + +import org.junit.jupiter.api.Test; +import org.rnorth.ducttape.TimeoutException; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.time.Duration; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +class ComposeContainerWithServicesTest { + + public static final File SIMPLE_COMPOSE_FILE = new File( + "src/test/resources/compose-scaling-multiple-containers.yml" + ); + + public static final File COMPOSE_FILE_WITH_INLINE_SCALE = new File( + "src/test/resources/compose-with-inline-scale-test.yml" + ); + + public static final File COMPOSE_FILE_WITH_HEALTHCHECK = new File( + "src/test/resources/docker-compose-healthcheck.yml" + ); + + @Test + void testDesiredSubsetOfServicesAreStarted() { + try ( + ComposeContainer compose = new ComposeContainer(DockerImageName.parse("docker:25.0.5"), SIMPLE_COMPOSE_FILE) + .withServices("redis") + ) { + compose.start(); + + verifyStartedContainers(compose, "redis-1"); + } + } + + @Test + void testDesiredSubsetOfScaledServicesAreStarted() { + try ( + ComposeContainer compose = new ComposeContainer(DockerImageName.parse("docker:25.0.5"), SIMPLE_COMPOSE_FILE) + .withScaledService("redis", 2) + ) { + compose.start(); + + verifyStartedContainers(compose, "redis-1", "redis-2"); + } + } + + @Test + void testDesiredSubsetOfSpecifiedAndScaledServicesAreStarted() { + try ( + ComposeContainer compose = new ComposeContainer(DockerImageName.parse("docker:25.0.5"), SIMPLE_COMPOSE_FILE) + .withServices("redis") + .withScaledService("redis", 2) + ) { + compose.start(); + + verifyStartedContainers(compose, "redis-1", "redis-2"); + } + } + + @Test + void testDesiredSubsetOfSpecifiedOrScaledServicesAreStarted() { + try ( + ComposeContainer compose = new ComposeContainer(DockerImageName.parse("docker:25.0.5"), SIMPLE_COMPOSE_FILE) + .withServices("other") + .withScaledService("redis", 2) + ) { + compose.start(); + + verifyStartedContainers(compose, "redis-1", "redis-2", "other-1"); + } + } + + @Test + void testAllServicesAreStartedIfNotSpecified() { + try ( + ComposeContainer compose = new ComposeContainer(DockerImageName.parse("docker:25.0.5"), SIMPLE_COMPOSE_FILE) + ) { + compose.start(); + + verifyStartedContainers(compose, "redis-1", "other-1"); + } + } + + @Test + void testScaleInComposeFileIsRespected() { + try ( + ComposeContainer compose = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + COMPOSE_FILE_WITH_INLINE_SCALE + ) + ) { + compose.start(); + + // the compose file includes `scale: 3` for the redis container + verifyStartedContainers(compose, "redis-1", "redis-2", "redis-3"); + } + } + + @Test + void testStartupTimeoutSetsTheHighestTimeout() { + assertThat( + catchThrowable(() -> { + try ( + ComposeContainer compose = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + SIMPLE_COMPOSE_FILE + ) + .withServices("redis") + .withStartupTimeout(Duration.ofMillis(1)) + .withExposedService( + "redis", + 80, + Wait.forListeningPort().withStartupTimeout(Duration.ofMinutes(1)) + ); + ) { + compose.start(); + } + }) + ) + .as("We expect a timeout from the startup timeout") + .isInstanceOf(TimeoutException.class); + } + + @Test + void testWaitingForHealthcheck() { + try ( + ComposeContainer compose = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + COMPOSE_FILE_WITH_HEALTHCHECK + ) + .waitingFor("redis", Wait.forHealthcheck().withStartupTimeout(Duration.ofMinutes(2))) + ) { + compose.start(); + + verifyStartedContainers(compose, "redis-1"); + } + } + + @Test + void testWaitingForHealthcheckWithRestartDoesNotCrash() { + try ( + ComposeContainer compose = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + COMPOSE_FILE_WITH_HEALTHCHECK + ) + .waitingFor("redis", Wait.forHealthcheck().withStartupTimeout(Duration.ofMinutes(1))) + ) { + compose.start(); + compose.stop(); + compose.start(); + + verifyStartedContainers(compose, "redis-1"); + } + } + + private void verifyStartedContainers(final ComposeContainer compose, final String... names) { + final List containerNames = compose + .listChildContainers() + .stream() + .flatMap(container -> Stream.of(container.getNames())) + .collect(Collectors.toList()); + + assertThat(containerNames) + .as("number of running services of docker-compose is the same as length of listOfServices") + .hasSize(names.length); + + for (final String expectedName : names) { + final long matches = containerNames.stream().filter(foundName -> foundName.endsWith(expectedName)).count(); + + assertThat(matches) + .as("container with name starting '" + expectedName + "' should be running") + .isEqualTo(1L); + } + } +} diff --git a/core/src/test/java/org/testcontainers/containers/ComposeOverridesTest.java b/core/src/test/java/org/testcontainers/containers/ComposeOverridesTest.java new file mode 100644 index 00000000000..ed948df5bd3 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/ComposeOverridesTest.java @@ -0,0 +1,96 @@ +package org.testcontainers.containers; + +import com.google.common.util.concurrent.Uninterruptibles; +import org.apache.commons.lang3.SystemUtils; +import org.assertj.core.api.Assumptions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.rnorth.ducttape.unreliables.Unreliables; +import org.testcontainers.utility.CommandLine; +import org.testcontainers.utility.DockerImageName; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.net.Socket; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +class ComposeOverridesTest { + + private static final String DOCKER_EXECUTABLE = SystemUtils.IS_OS_WINDOWS ? "docker.exe" : "docker"; + + private static final File BASE_COMPOSE_FILE = new File("src/test/resources/docker-compose-base.yml"); + + private static final String BASE_ENV_VAR = "bar=base"; + + private static final File OVERRIDE_COMPOSE_FILE = new File( + "src/test/resources/docker-compose-non-default-override.yml" + ); + + private static final String OVERRIDE_ENV_VAR = "bar=overwritten"; + + private static final int SERVICE_PORT = 3000; + + private static final String SERVICE_NAME = "alpine-1"; + + public static Iterable data() { + return Arrays.asList( + new Object[][] { + { true, BASE_ENV_VAR, new File[] { BASE_COMPOSE_FILE } }, + { true, OVERRIDE_ENV_VAR, new File[] { BASE_COMPOSE_FILE, OVERRIDE_COMPOSE_FILE } }, + { false, BASE_ENV_VAR, new File[] { BASE_COMPOSE_FILE } }, + { false, OVERRIDE_ENV_VAR, new File[] { BASE_COMPOSE_FILE, OVERRIDE_COMPOSE_FILE } }, + } + ); + } + + @ParameterizedTest(name = "{index}: local[{0}], composeFiles[{2}], expectedEnvVar[{1}]") + @MethodSource("data") + void test(boolean localMode, String expectedEnvVar, File... composeFiles) { + ComposeContainer compose; + if (localMode) { + Assumptions + .assumeThat(CommandLine.executableExists(DOCKER_EXECUTABLE)) + .as("docker executable exists") + .isTrue(); + compose = new ComposeContainer(composeFiles).withExposedService(SERVICE_NAME, SERVICE_PORT); + } else { + compose = + new ComposeContainer(DockerImageName.parse("docker:25.0.2"), composeFiles) + .withExposedService(SERVICE_NAME, SERVICE_PORT); + } + + compose.start(); + + BufferedReader br = Unreliables.retryUntilSuccess( + 10, + TimeUnit.SECONDS, + () -> { + Uninterruptibles.sleepUninterruptibly(1, TimeUnit.SECONDS); + + Socket socket = new Socket( + compose.getServiceHost(SERVICE_NAME, SERVICE_PORT), + compose.getServicePort(SERVICE_NAME, SERVICE_PORT) + ); + return new BufferedReader(new InputStreamReader(socket.getInputStream())); + } + ); + + Unreliables.retryUntilTrue( + 10, + TimeUnit.SECONDS, + () -> { + while (br.ready()) { + String line = br.readLine(); + if (line.contains(expectedEnvVar)) { + return true; + } + } + Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS); + return false; + } + ); + compose.stop(); + } +} diff --git a/core/src/test/java/org/testcontainers/containers/ComposeProfilesOptionTest.java b/core/src/test/java/org/testcontainers/containers/ComposeProfilesOptionTest.java new file mode 100644 index 00000000000..3770d617ebb --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/ComposeProfilesOptionTest.java @@ -0,0 +1,44 @@ +package org.testcontainers.containers; + +import org.assertj.core.api.Assumptions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.utility.CommandLine; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; + +import static org.assertj.core.api.Assertions.assertThat; + +class ComposeProfilesOptionTest { + + public static Boolean[] local() { + return new Boolean[] { Boolean.TRUE, Boolean.FALSE }; + } + + public static final File COMPOSE_FILE = new File("src/test/resources/compose-profile-option/compose-test.yml"); + + @ParameterizedTest + @MethodSource("local") + void testProfileOption(boolean localMode) { + ComposeContainer compose; + if (localMode) { + Assumptions + .assumeThat(CommandLine.executableExists(ComposeContainer.COMPOSE_EXECUTABLE)) + .as("docker executable exists") + .isTrue(); + // composeContainerWithLocalCompose { + compose = + new ComposeContainer(COMPOSE_FILE) + // } + .withOptions("--profile=cache"); + } else { + compose = + new ComposeContainer(DockerImageName.parse("docker:25.0.2"), COMPOSE_FILE) + .withOptions("--profile=cache"); + } + compose.start(); + assertThat(compose.listChildContainers()).hasSize(1); + compose.stop(); + } +} diff --git a/core/src/test/java/org/testcontainers/containers/ContainerStateTest.java b/core/src/test/java/org/testcontainers/containers/ContainerStateTest.java new file mode 100644 index 00000000000..7b37bc1926f --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/ContainerStateTest.java @@ -0,0 +1,38 @@ +package org.testcontainers.containers; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ContainerStateTest { + + public static Object[][] params() { + return new Object[][] { + new Object[] { "regular mapping", "80:8080/tcp", Collections.singletonList(80) }, + new Object[] { "regular mapping with host", "127.0.0.1:80:8080/tcp", Collections.singletonList(80) }, + new Object[] { "zero port without host", ":0:8080/tcp", Collections.emptyList() }, + new Object[] { "missing port with host", "0.0.0.0:0:8080/tcp", Collections.emptyList() }, + new Object[] { "zero port (synthetic case)", "0:8080/tcp", Collections.emptyList() }, + new Object[] { "missing port", ":8080/tcp", Collections.emptyList() }, + }; + } + + @ParameterizedTest(name = "{0} ({1} -> {2})") + @MethodSource("params") + void test(String name, String testSet, List expectedResult) { + ContainerState containerState = mock(ContainerState.class); + doCallRealMethod().when(containerState).getBoundPortNumbers(); + + when(containerState.getPortBindings()).thenReturn(Collections.singletonList(testSet)); + + List result = containerState.getBoundPortNumbers(); + assertThat(result).hasSameElementsAs(expectedResult); + } +} diff --git a/core/src/test/java/org/testcontainers/containers/DockerComposeContainerCustomImageTest.java b/core/src/test/java/org/testcontainers/containers/DockerComposeContainerCustomImageTest.java new file mode 100644 index 00000000000..46ee86a8c23 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/DockerComposeContainerCustomImageTest.java @@ -0,0 +1,50 @@ +package org.testcontainers.containers; + +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class DockerComposeContainerCustomImageTest { + + public static final String DOCKER_IMAGE = "docker/compose:debian-1.29.2"; + + private static final String COMPOSE_FILE_PATH = "src/test/resources/scaled-compose-test.yml"; + + @Test + void testWithCustomDockerImage() { + DockerComposeContainer composeContainer = new DockerComposeContainer<>( + DockerImageName.parse(DOCKER_IMAGE), + "testing", + new File(COMPOSE_FILE_PATH) + ); + composeContainer.start(); + verifyContainerCreation(composeContainer); + composeContainer.stop(); + } + + @Test + void testWithCustomDockerImageAndIdentifier() { + DockerComposeContainer composeContainer = new DockerComposeContainer( + DockerImageName.parse(DOCKER_IMAGE), + "myidentifier", + new File(COMPOSE_FILE_PATH) + ); + composeContainer.start(); + verifyContainerCreation(composeContainer); + composeContainer.stop(); + } + + private void verifyContainerCreation(DockerComposeContainer composeContainer) { + Optional redis = composeContainer.getContainerByServiceName("redis"); + assertThat(redis) + .hasValueSatisfying(container -> { + assertThat(container.isRunning()).isTrue(); + assertThat(container.getContainerInfo().getConfig().getLabels()) + .containsEntry("com.docker.compose.version", "1.29.2"); + }); + } +} diff --git a/core/src/test/java/org/testcontainers/containers/DockerComposeContainerWithServicesTest.java b/core/src/test/java/org/testcontainers/containers/DockerComposeContainerWithServicesTest.java new file mode 100644 index 00000000000..52d8d003003 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/DockerComposeContainerWithServicesTest.java @@ -0,0 +1,198 @@ +package org.testcontainers.containers; + +import org.junit.jupiter.api.Test; +import org.rnorth.ducttape.TimeoutException; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.time.Duration; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +class DockerComposeContainerWithServicesTest { + + public static final File SIMPLE_COMPOSE_FILE = new File( + "src/test/resources/compose-scaling-multiple-containers.yml" + ); + + public static final File COMPOSE_FILE_WITH_INLINE_SCALE = new File( + "src/test/resources/compose-with-inline-scale-test.yml" + ); + + public static final File COMPOSE_FILE_WITH_HEALTHCHECK = new File( + "src/test/resources/docker-compose-healthcheck.yml" + ); + + @Test + void testDesiredSubsetOfServicesAreStarted() { + try ( + DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + SIMPLE_COMPOSE_FILE + ) + .withServices("redis") + ) { + compose.start(); + + verifyStartedContainers(compose, "redis_1"); + } + } + + @Test + void testDesiredSubsetOfScaledServicesAreStarted() { + try ( + DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + SIMPLE_COMPOSE_FILE + ) + .withScaledService("redis", 2) + ) { + compose.start(); + + verifyStartedContainers(compose, "redis_1", "redis_2"); + } + } + + @Test + void testDesiredSubsetOfSpecifiedAndScaledServicesAreStarted() { + try ( + DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + SIMPLE_COMPOSE_FILE + ) + .withServices("redis") + .withScaledService("redis", 2) + ) { + compose.start(); + + verifyStartedContainers(compose, "redis_1", "redis_2"); + } + } + + @Test + void testDesiredSubsetOfSpecifiedOrScaledServicesAreStarted() { + try ( + DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + SIMPLE_COMPOSE_FILE + ) + .withServices("other") + .withScaledService("redis", 2) + ) { + compose.start(); + + verifyStartedContainers(compose, "redis_1", "redis_2", "other_1"); + } + } + + @Test + void testAllServicesAreStartedIfNotSpecified() { + try ( + DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + SIMPLE_COMPOSE_FILE + ) + ) { + compose.start(); + + verifyStartedContainers(compose, "redis_1", "other_1"); + } + } + + @Test + void testScaleInComposeFileIsRespected() { + try ( + DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + COMPOSE_FILE_WITH_INLINE_SCALE + ) + ) { + compose.start(); + + // the compose file includes `scale: 3` for the redis container + verifyStartedContainers(compose, "redis_1", "redis_2", "redis_3"); + } + } + + @Test + void testStartupTimeoutSetsTheHighestTimeout() { + assertThat( + catchThrowable(() -> { + try ( + DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + SIMPLE_COMPOSE_FILE + ) + .withServices("redis") + .withStartupTimeout(Duration.ofMillis(1)) + .withExposedService( + "redis", + 80, + Wait.forListeningPort().withStartupTimeout(Duration.ofMinutes(1)) + ); + ) { + compose.start(); + } + }) + ) + .as("We expect a timeout from the startup timeout") + .isInstanceOf(TimeoutException.class); + } + + @Test + void testWaitingForHealthcheck() { + try ( + DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + COMPOSE_FILE_WITH_HEALTHCHECK + ) + .waitingFor("redis", Wait.forHealthcheck().withStartupTimeout(Duration.ofMinutes(2))) + ) { + compose.start(); + + verifyStartedContainers(compose, "redis_1"); + } + } + + @Test + void testWaitingForHealthcheckWithRestartDoesNotCrash() { + try ( + DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + COMPOSE_FILE_WITH_HEALTHCHECK + ) + .waitingFor("redis", Wait.forHealthcheck().withStartupTimeout(Duration.ofMinutes(1))) + ) { + compose.start(); + compose.stop(); + compose.start(); + + verifyStartedContainers(compose, "redis_1"); + } + } + + private void verifyStartedContainers(final DockerComposeContainer compose, final String... names) { + final List containerNames = compose + .listChildContainers() + .stream() + .flatMap(container -> Stream.of(container.getNames())) + .collect(Collectors.toList()); + + assertThat(containerNames) + .as("number of running services of docker-compose is the same as length of listOfServices") + .hasSize(names.length); + + for (final String expectedName : names) { + final long matches = containerNames.stream().filter(foundName -> foundName.endsWith(expectedName)).count(); + + assertThat(matches) + .as("container with name starting '" + expectedName + "' should be running") + .isEqualTo(1L); + } + } +} diff --git a/core/src/test/java/org/testcontainers/containers/DockerComposeFilesTest.java b/core/src/test/java/org/testcontainers/containers/DockerComposeFilesTest.java new file mode 100644 index 00000000000..4704817c0e0 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/DockerComposeFilesTest.java @@ -0,0 +1,32 @@ +package org.testcontainers.containers; + +import com.google.common.collect.Lists; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.assertj.core.api.Assertions.assertThat; + +class DockerComposeFilesTest { + + @Test + void shouldGetDependencyImages() { + DockerComposeFiles dockerComposeFiles = new DockerComposeFiles( + Lists.newArrayList(new File("src/test/resources/docker-compose-imagename-parsing-v2.yml")) + ); + assertThat(dockerComposeFiles.getDependencyImages()) + .containsExactlyInAnyOrder("postgres:latest", "redis:latest", "mysql:latest"); + } + + @Test + void shouldGetDependencyImagesWhenOverriding() { + DockerComposeFiles dockerComposeFiles = new DockerComposeFiles( + Lists.newArrayList( + new File("src/test/resources/docker-compose-imagename-overriding-a.yml"), + new File("src/test/resources/docker-compose-imagename-overriding-b.yml") + ) + ); + assertThat(dockerComposeFiles.getDependencyImages()) + .containsExactlyInAnyOrder("alpine:3.17", "redis:b", "mysql:b", "aservice:latest"); + } +} diff --git a/core/src/test/java/org/testcontainers/containers/DockerComposeOverridesTest.java b/core/src/test/java/org/testcontainers/containers/DockerComposeOverridesTest.java new file mode 100644 index 00000000000..e3bd499cc45 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/DockerComposeOverridesTest.java @@ -0,0 +1,97 @@ +package org.testcontainers.containers; + +import com.google.common.util.concurrent.Uninterruptibles; +import org.assertj.core.api.Assumptions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.rnorth.ducttape.unreliables.Unreliables; +import org.testcontainers.utility.CommandLine; +import org.testcontainers.utility.DockerImageName; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.net.Socket; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +class DockerComposeOverridesTest { + + private static final File BASE_COMPOSE_FILE = new File("src/test/resources/docker-compose-base.yml"); + + private static final String BASE_ENV_VAR = "bar=base"; + + private static final File OVERRIDE_COMPOSE_FILE = new File( + "src/test/resources/docker-compose-non-default-override.yml" + ); + + private static final String OVERRIDE_ENV_VAR = "bar=overwritten"; + + private static final int SERVICE_PORT = 3000; + + private static final String SERVICE_NAME = "alpine_1"; + + public static Iterable data() { + return Arrays.asList( + new Object[][] { + { true, BASE_ENV_VAR, new File[] { BASE_COMPOSE_FILE } }, + { true, OVERRIDE_ENV_VAR, new File[] { BASE_COMPOSE_FILE, OVERRIDE_COMPOSE_FILE } }, + { false, BASE_ENV_VAR, new File[] { BASE_COMPOSE_FILE } }, + { false, OVERRIDE_ENV_VAR, new File[] { BASE_COMPOSE_FILE, OVERRIDE_COMPOSE_FILE } }, + } + ); + } + + @ParameterizedTest(name = "{index}: local[{0}], composeFiles[{2}], expectedEnvVar[{1}]") + @MethodSource("data") + void test(boolean localMode, String expectedEnvVar, File... composeFiles) { + DockerComposeContainer compose; + if (localMode) { + Assumptions + .assumeThat(CommandLine.executableExists(DockerComposeContainer.COMPOSE_EXECUTABLE)) + .as("docker-compose executable exists") + .isTrue(); + Assumptions + .assumeThat(CommandLine.runShellCommand("docker-compose", "--version")) + .doesNotStartWith("Docker Compose version v2"); + + compose = new DockerComposeContainer(composeFiles).withExposedService(SERVICE_NAME, SERVICE_PORT); + } else { + compose = + new DockerComposeContainer(DockerImageName.parse("docker/compose:debian-1.29.2"), composeFiles) + .withExposedService(SERVICE_NAME, SERVICE_PORT); + } + + compose.start(); + + BufferedReader br = Unreliables.retryUntilSuccess( + 10, + TimeUnit.SECONDS, + () -> { + Uninterruptibles.sleepUninterruptibly(1, TimeUnit.SECONDS); + + Socket socket = new Socket( + compose.getServiceHost(SERVICE_NAME, SERVICE_PORT), + compose.getServicePort(SERVICE_NAME, SERVICE_PORT) + ); + return new BufferedReader(new InputStreamReader(socket.getInputStream())); + } + ); + + Unreliables.retryUntilTrue( + 10, + TimeUnit.SECONDS, + () -> { + while (br.ready()) { + String line = br.readLine(); + if (line.contains(expectedEnvVar)) { + return true; + } + } + Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS); + return false; + } + ); + compose.stop(); + } +} diff --git a/core/src/test/java/org/testcontainers/containers/DockerComposeProfilesOptionTest.java b/core/src/test/java/org/testcontainers/containers/DockerComposeProfilesOptionTest.java new file mode 100644 index 00000000000..46f29b0a281 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/DockerComposeProfilesOptionTest.java @@ -0,0 +1,45 @@ +package org.testcontainers.containers; + +import org.assertj.core.api.Assumptions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.utility.CommandLine; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; + +import static org.assertj.core.api.Assertions.assertThat; + +class DockerComposeProfilesOptionTest { + + public static Boolean[] local() { + return new Boolean[] { Boolean.TRUE, Boolean.FALSE }; + } + + public static final File COMPOSE_FILE = new File("src/test/resources/compose-profile-option/compose-test.yml"); + + @ParameterizedTest(name = "{0}") + @MethodSource("local") + void testProfileOption(boolean localMode) { + DockerComposeContainer compose; + if (localMode) { + Assumptions + .assumeThat(CommandLine.executableExists(DockerComposeContainer.COMPOSE_EXECUTABLE)) + .as("docker-compose executable exists") + .isTrue(); + Assumptions + .assumeThat(CommandLine.runShellCommand("docker-compose", "--version")) + .doesNotStartWith("Docker Compose version v2"); + + compose = new DockerComposeContainer<>(COMPOSE_FILE).withOptions("--profile=cache"); + } else { + compose = + new DockerComposeContainer<>(DockerImageName.parse("docker/compose:debian-1.29.2"), COMPOSE_FILE) + .withOptions("--profile=cache"); + } + + compose.start(); + assertThat(compose.listChildContainers()).hasSize(1); + compose.stop(); + } +} diff --git a/core/src/test/java/org/testcontainers/containers/DockerMcpGatewayContainerTest.java b/core/src/test/java/org/testcontainers/containers/DockerMcpGatewayContainerTest.java new file mode 100644 index 00000000000..cecd9f4da6f --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/DockerMcpGatewayContainerTest.java @@ -0,0 +1,37 @@ +package org.testcontainers.containers; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; + +class DockerMcpGatewayContainerTest { + + @Test + void serviceSuccessfullyStarts() { + try (DockerMcpGatewayContainer gateway = new DockerMcpGatewayContainer("docker/mcp-gateway:latest")) { + gateway.start(); + + assertThat(gateway.isRunning()).isTrue(); + } + } + + @Test + void gatewayStartsWithServers() { + try ( + // container { + DockerMcpGatewayContainer gateway = new DockerMcpGatewayContainer("docker/mcp-gateway:latest") + .withServer("curl", "curl") + .withServer("brave", "brave_local_search", "brave_web_search") + .withServer("github-official", Collections.singletonList("add_issue_comment")) + .withSecret("brave.api_key", "test_key") + .withSecrets(Collections.singletonMap("github.personal_access_token", "test_token")) + // } + ) { + gateway.start(); + + assertThat(gateway.getLogs()).contains("4 tools listed"); + } + } +} diff --git a/core/src/test/java/org/testcontainers/containers/DockerModelRunnerContainerTest.java b/core/src/test/java/org/testcontainers/containers/DockerModelRunnerContainerTest.java new file mode 100644 index 00000000000..b215a373cf5 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/DockerModelRunnerContainerTest.java @@ -0,0 +1,49 @@ +package org.testcontainers.containers; + +import io.restassured.RestAssured; +import io.restassured.response.Response; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +class DockerModelRunnerContainerTest { + + @Test + void checkStatus() { + assumeThat(System.getenv("CI")).isNull(); + + try ( + // container { + DockerModelRunnerContainer dmr = new DockerModelRunnerContainer("alpine/socat:1.7.4.3-r0") + // } + ) { + dmr.start(); + + Response modelResponse = RestAssured.get(dmr.getBaseEndpoint() + "/status").thenReturn(); + assertThat(modelResponse.body().asString()).contains("The service is running"); + } + } + + @Test + void pullsModelAndExposesInference() { + assumeThat(System.getenv("CI")).isNull(); + + String modelName = "ai/smollm2:360M-Q4_K_M"; + + try ( + // pullModel { + DockerModelRunnerContainer dmr = new DockerModelRunnerContainer("alpine/socat:1.7.4.3-r0") + .withModel(modelName) + // } + ) { + dmr.start(); + + Response modelResponse = RestAssured.get(dmr.getBaseEndpoint() + "/models").thenReturn(); + assertThat(modelResponse.body().jsonPath().getList("tags.flatten()")).contains(modelName); + + Response openAiResponse = RestAssured.get(dmr.getOpenAIEndpoint() + "/v1/models").prettyPeek().thenReturn(); + assertThat(openAiResponse.body().jsonPath().getList("data.id")).contains(modelName); + } + } +} diff --git a/core/src/test/java/org/testcontainers/containers/ExposedHostTest.java b/core/src/test/java/org/testcontainers/containers/ExposedHostTest.java new file mode 100644 index 00000000000..50bd17d0f0d --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/ExposedHostTest.java @@ -0,0 +1,202 @@ +package org.testcontainers.containers; + +import com.google.common.collect.ImmutableMap; +import com.sun.net.httpserver.HttpServer; +import lombok.SneakyThrows; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.TestImages; +import org.testcontainers.Testcontainers; +import org.testcontainers.utility.TestcontainersConfiguration; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +class ExposedHostTest { + + private static HttpServer server; + + @BeforeAll + public static void setUpClass() throws Exception { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/", + exchange -> { + byte[] content = "Hello World!".getBytes(); + exchange.sendResponseHeaders(200, content.length); + try (OutputStream responseBody = exchange.getResponseBody()) { + responseBody.write(content); + responseBody.flush(); + } + } + ); + server.start(); + } + + @AfterAll + public static void tearDownClass() { + server.stop(0); + } + + @AfterEach + public void tearDown() { + PortForwardingContainer.INSTANCE.reset(); + } + + @Test + void testExposedHostAfterContainerIsStarted() { + try (GenericContainer container = new GenericContainer<>(tinyContainerDef()).withAccessToHost(true)) { + container.start(); + Testcontainers.exposeHostPorts(server.getAddress().getPort()); + assertResponse(container, server.getAddress().getPort()); + } + } + + @Test + void testExposedHost() { + Testcontainers.exposeHostPorts(server.getAddress().getPort()); + assertResponse(new GenericContainer<>(tinyContainerDef()), server.getAddress().getPort()); + } + + @Test + void testExposedHostWithNetwork() { + Testcontainers.exposeHostPorts(server.getAddress().getPort()); + try (Network network = Network.newNetwork()) { + assertResponse( + new GenericContainer<>(tinyContainerDef()).withNetwork(network), + server.getAddress().getPort() + ); + } + } + + @Test + void testExposedHostPortOnFixedInternalPorts() { + Testcontainers.exposeHostPorts(ImmutableMap.of(server.getAddress().getPort(), 80)); + Testcontainers.exposeHostPorts(ImmutableMap.of(server.getAddress().getPort(), 81)); + + assertResponse(new GenericContainer<>(tinyContainerDef()), 80); + assertResponse(new GenericContainer<>(tinyContainerDef()), 81); + } + + @Test + void testExposedHostWithReusableContainerAndFixedNetworkName() throws IOException, InterruptedException { + assumeThat(TestcontainersConfiguration.getInstance().environmentSupportsReuse()).isTrue(); + Network network = createReusableNetwork(UUID.randomUUID()); + Testcontainers.exposeHostPorts(server.getAddress().getPort()); + + GenericContainer container = new GenericContainer<>(tinyContainerDef()).withReuse(true).withNetwork(network); + container.start(); + + assertHttpResponseFromHost(container, server.getAddress().getPort()); + + PortForwardingContainer.INSTANCE.reset(); + Testcontainers.exposeHostPorts(server.getAddress().getPort()); + + GenericContainer reusedContainer = new GenericContainer<>(tinyContainerDef()) + .withReuse(true) + .withNetwork(network); + reusedContainer.start(); + + assertThat(reusedContainer.getContainerId()).isEqualTo(container.getContainerId()); + assertHttpResponseFromHost(reusedContainer, server.getAddress().getPort()); + + container.stop(); + reusedContainer.stop(); + DockerClientFactory.lazyClient().removeNetworkCmd(network.getId()).exec(); + } + + @Test + void testExposedHostOnFixedInternalPortsWithReusableContainerAndFixedNetworkName() + throws IOException, InterruptedException { + assumeThat(TestcontainersConfiguration.getInstance().environmentSupportsReuse()).isTrue(); + Network network = createReusableNetwork(UUID.randomUUID()); + Testcontainers.exposeHostPorts(ImmutableMap.of(server.getAddress().getPort(), 1234)); + + GenericContainer container = new GenericContainer<>(tinyContainerDef()).withReuse(true).withNetwork(network); + container.start(); + + assertHttpResponseFromHost(container, 1234); + + PortForwardingContainer.INSTANCE.reset(); + Testcontainers.exposeHostPorts(ImmutableMap.of(server.getAddress().getPort(), 1234)); + + GenericContainer reusedContainer = new GenericContainer<>(tinyContainerDef()) + .withReuse(true) + .withNetwork(network); + reusedContainer.start(); + + assertThat(reusedContainer.getContainerId()).isEqualTo(container.getContainerId()); + assertHttpResponseFromHost(reusedContainer, 1234); + + container.stop(); + reusedContainer.stop(); + DockerClientFactory.lazyClient().removeNetworkCmd(network.getId()).exec(); + } + + @SneakyThrows + protected void assertResponse(GenericContainer container, int port) { + try { + container.start(); + + String response = container + .execInContainer("wget", "-O", "-", "http://host.testcontainers.internal:" + port) + .getStdout(); + + assertThat(response).as("received response").isEqualTo("Hello World!"); + } finally { + container.stop(); + } + } + + private ContainerDef tinyContainerDef() { + return new TinyContainerDef(); + } + + private static class TinyContainerDef extends ContainerDef { + + TinyContainerDef() { + setImage(TestImages.TINY_IMAGE); + setCommand("top"); + } + } + + private void assertHttpResponseFromHost(GenericContainer container, int port) + throws IOException, InterruptedException { + String httpResponseFromHost = container + .execInContainer("wget", "-O", "-", "http://host.testcontainers.internal:" + port) + .getStdout(); + assertThat(httpResponseFromHost).isEqualTo("Hello World!"); + } + + private static Network createReusableNetwork(UUID name) { + String networkName = name.toString(); + Network network = new Network() { + @Override + public String getId() { + return networkName; + } + + @Override + public void close() {} + }; + + List networks = DockerClientFactory + .lazyClient() + .listNetworksCmd() + .withNameFilter(networkName) + .exec(); + if (networks.isEmpty()) { + Network.builder().createNetworkCmdModifier(cmd -> cmd.withName(networkName)).build().getId(); + } + return network; + } +} diff --git a/core/src/test/java/org/testcontainers/containers/GenericContainerTest.java b/core/src/test/java/org/testcontainers/containers/GenericContainerTest.java new file mode 100644 index 00000000000..37aac1b0af9 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/GenericContainerTest.java @@ -0,0 +1,411 @@ +package org.testcontainers.containers; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.command.InspectContainerResponse.ContainerState; +import com.github.dockerjava.api.model.Container; +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.Info; +import com.github.dockerjava.api.model.Ports; +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableList; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FileUtils; +import org.assertj.core.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.rnorth.ducttape.unreliables.Unreliables; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.TestImages; +import org.testcontainers.containers.startupcheck.StartupCheckStrategy; +import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.RemoteDockerImage; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.assertj.core.api.Assumptions.assumeThat; + +class GenericContainerTest { + + @Test + void shouldReportOOMAfterWait() { + Info info = DockerClientFactory.instance().client().infoCmd().exec(); + // Poor man's rootless Docker detection :D + Assumptions.assumeThat(info.getSecurityOptions()).doesNotContain("name=rootless"); + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withStartupCheckStrategy(new NoopStartupCheckStrategy()) + .waitingFor(new WaitForExitedState(ContainerState::getOOMKilled)) + .withCreateContainerCmdModifier(it -> { + it + .getHostConfig() + .withMemory(20 * FileUtils.ONE_MB) + .withMemorySwappiness(0L) + .withMemorySwap(0L) + .withMemoryReservation(0L) + .withKernelMemory(16 * FileUtils.ONE_MB); + }) + .withCommand("sh", "-c", "A='0123456789'; for i in $(seq 0 32); do A=$A$A; done; sleep 10m") + ) { + assertThatThrownBy(container::start) + .hasStackTraceContaining("Wait strategy failed. Container crashed with out-of-memory (OOMKilled)") + .hasStackTraceContaining("Nope!"); + } + } + + @Test + void shouldReportErrorAfterWait() { + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withStartupCheckStrategy(new NoopStartupCheckStrategy()) + .waitingFor(new WaitForExitedState(state -> state.getExitCode() > 0)) + .withCommand("sh", "-c", "usleep 100; exit 123") + ) { + assertThatThrownBy(container::start) + .hasStackTraceContaining("Container startup failed for image " + TestImages.TINY_IMAGE) + .hasStackTraceContaining("Wait strategy failed. Container exited with code 123") + .hasStackTraceContaining("Nope!"); + } + } + + @Test + void shouldCopyTransferableAsFile() { + try ( + // transferableFile { + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withStartupCheckStrategy(new NoopStartupCheckStrategy()) + .withCopyToContainer(Transferable.of("test"), "/tmp/test") + .waitingFor(new WaitForExitedState(state -> state.getExitCodeLong() > 0)) + .withCommand("sh", "-c", "grep -q test /tmp/test && exit 100") + // } + ) { + assertThatThrownBy(container::start) + .hasStackTraceContaining("Wait strategy failed. Container exited with code 100") + .hasStackTraceContaining("Nope!"); + } + } + + @Test + void shouldCopyTransferableAsFileWithFileMode() { + try ( + // transferableWithFileMode { + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withStartupCheckStrategy(new NoopStartupCheckStrategy()) + .withCopyToContainer(Transferable.of("test", 0777), "/tmp/test") + .waitingFor(new WaitForExitedState(state -> state.getExitCodeLong() > 0)) + .withCommand("sh", "-c", "ls -ll /tmp | grep '\\-rwxrwxrwx\\|test' && exit 100") + // } + ) { + assertThatThrownBy(container::start) + .hasStackTraceContaining("Wait strategy failed. Container exited with code 100") + .hasStackTraceContaining("Nope!"); + } + } + + @Test + void shouldCopyTransferableAfterMountableFile() { + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withStartupCheckStrategy(new NoopStartupCheckStrategy()) + .withCopyFileToContainer(MountableFile.forClasspathResource("test_copy_to_container.txt"), "/tmp/test") + .withCopyToContainer(Transferable.of("test"), "/tmp/test") + .waitingFor(new WaitForExitedState(state -> state.getExitCodeLong() > 0)) + .withCommand("sh", "-c", "grep -q test /tmp/test && exit 100") + ) { + assertThatThrownBy(container::start) + .hasStackTraceContaining("Wait strategy failed. Container exited with code 100") + .hasStackTraceContaining("Nope!"); + } + } + + @Test + void shouldOnlyPublishExposedPorts() { + ImageFromDockerfile image = new ImageFromDockerfile("publish-multiple") + .withDockerfileFromBuilder(builder -> { + builder + .from("testcontainers/helloworld:1.1.0") // + .expose(8080, 8081) + .build(); + }); + try (GenericContainer container = new GenericContainer<>(image).withExposedPorts(8080)) { + container.start(); + + InspectContainerResponse inspectedContainer = container.getContainerInfo(); + + List exposedPorts = Arrays + .stream(inspectedContainer.getConfig().getExposedPorts()) + .map(ExposedPort::getPort) + .collect(Collectors.toList()); + + assertThat(exposedPorts).as("the exposed ports are all of those EXPOSEd by the image").contains(8080, 8081); + + Map hostBindings = inspectedContainer + .getHostConfig() + .getPortBindings() + .getBindings(); + assertThat(hostBindings).as("only 1 port is bound on the host (published)").hasSize(1); + + Integer mappedPort = container.getMappedPort(8080); + assertThat(mappedPort != 8080).as("port 8080 is bound to a different port on the host").isTrue(); + + assertThat(catchThrowable(() -> container.getMappedPort(8081))) + .as("trying to get a non-bound port mapping fails") + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + void shouldWaitUntilExposedPortIsMapped() { + ImageFromDockerfile image = new ImageFromDockerfile("publish-multiple") + .withDockerfileFromBuilder(builder -> { + builder + .from("testcontainers/helloworld:1.1.0") + .expose(8080, 8081) // one additional port exposed in image + .build(); + }); + + try ( + GenericContainer container = new GenericContainer<>(image) + .withExposedPorts(8080) + .withCreateContainerCmdModifier(it -> it.withExposedPorts(ExposedPort.tcp(8082))) // another port exposed by modifier + ) { + container.start(); + + assertThat(container.getExposedPorts()).as("Only withExposedPort should be exposed").hasSize(1); + assertThat(container.getExposedPorts()).as("withExposedPort should be exposed").contains(8080); + } + } + + @Test + void testArchitectureCheck() { + assumeThat(DockerClientFactory.instance().client().versionCmd().exec().getArch()).isNotEqualTo("amd64"); + // Choose an image that is *different* from the server architecture--this ensures we always get a warning. + final String image; + if (DockerClientFactory.instance().client().versionCmd().exec().getArch().equals("amd64")) { + // arm64 image + image = "testcontainers/sshd@sha256:f701fa4ae2cd25ad2b2ea2df1aad00980f67bacdd03958a2d7d52ee63d7fb3e8"; + } else { + // amd64 image + image = "testcontainers/sshd@sha256:7879c6c99eeab01f1c6beb2c240d49a70430ef2d52f454765ec9707f547ef6f1"; + } + + try (GenericContainer container = new GenericContainer<>(image)) { + // Grab a copy of everything that is logged when we start the container + ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) container.logger(); + ListAppender listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + + container.start(); + + String regexMatch = "The architecture '\\S+' for image .*"; + assertThat(listAppender.list) + .describedAs( + "Received log list does not have a message matching '" + + regexMatch + + "': " + + listAppender.list.toString() + ) + .filteredOn(event -> event.getMessage().matches(regexMatch)) + .isNotEmpty(); + } + } + + @Test + void shouldReturnTheProvidedImage() { + GenericContainer container = new GenericContainer(TestImages.REDIS_IMAGE); + assertThat(container.getImage().get()).isEqualTo("redis:6-alpine"); + container.setImage(new RemoteDockerImage(TestImages.ALPINE_IMAGE)); + assertThat(container.getImage().get()).isEqualTo("alpine:3.17"); + } + + @Test + void shouldContainDefaultNetworkAlias() { + try (GenericContainer container = new GenericContainer<>("testcontainers/helloworld:1.1.0")) { + container.start(); + assertThat(container.getNetworkAliases()).hasSize(1); + } + } + + @Test + void shouldContainDefaultNetworkAliasWhenUsingGenericContainer() { + try (HelloWorldContainer container = new HelloWorldContainer("testcontainers/helloworld:1.1.0")) { + container.start(); + assertThat(container.getNetworkAliases()).hasSize(1); + } + } + + @Test + void shouldContainDefaultNetworkAliasWhenUsingContainerDef() { + try (TcHelloWorldContainer container = new TcHelloWorldContainer("testcontainers/helloworld:1.1.0")) { + container.start(); + assertThat(container.getNetworkAliases()).hasSize(1); + } + } + + @Test + void shouldRespectWaitStrategy() { + try ( + HelloWorldLogStrategyContainer container = new HelloWorldLogStrategyContainer( + "testcontainers/helloworld:1.1.0" + ) + ) { + container.setWaitStrategy(Wait.forLogMessage(".*Starting server on port.*", 1)); + container.start(); + assertThat((LogMessageWaitStrategy) container.getWaitStrategy()) + .extracting("regEx", "times") + .containsExactly(".*Starting server on port.*", 1); + } + } + + @Test + void testStartupAttemptsDoesNotLeaveContainersRunningWhenWrongWaitStrategyIsUsed() { + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withLabel("waitstrategy", "wrong") + .withStartupAttempts(3) + .waitingFor( + Wait.forLogMessage("this text does not exist in logs", 1).withStartupTimeout(Duration.ofMillis(1)) + ) + .withCommand("tail", "-f", "/dev/null"); + ) { + assertThatThrownBy(container::start).hasStackTraceContaining("Retry limit hit with exception"); + } + assertThat(reportLeakedContainers()).isEmpty(); + } + + private static Optional reportLeakedContainers() { + @SuppressWarnings("resource") // Throws when close is attempted, as this is a global instance. + DockerClient dockerClient = DockerClientFactory.lazyClient(); + + List containers = dockerClient + .listContainersCmd() + .withAncestorFilter(Collections.singletonList("alpine:3.17")) + .withLabelFilter( + Arrays.asList( + DockerClientFactory.TESTCONTAINERS_SESSION_ID_LABEL + "=" + DockerClientFactory.SESSION_ID, + "waitstrategy=wrong" + ) + ) + // ignore status "exited" - for example, failed containers after using `withStartupAttempts()` + .withStatusFilter(Arrays.asList("created", "restarting", "running", "paused")) + .exec() + .stream() + .collect(ImmutableList.toImmutableList()); + + if (containers.isEmpty()) { + return Optional.empty(); + } + + return Optional.of( + String.format( + "Leaked containers: %s", + containers + .stream() + .map(container -> { + return MoreObjects + .toStringHelper("container") + .add("id", container.getId()) + .add("image", container.getImage()) + .add("imageId", container.getImageId()) + .toString(); + }) + .collect(Collectors.joining(", ", "[", "]")) + ) + ); + } + + static class NoopStartupCheckStrategy extends StartupCheckStrategy { + + @Override + public StartupStatus checkStartupState(DockerClient dockerClient, String containerId) { + return StartupStatus.SUCCESSFUL; + } + } + + @RequiredArgsConstructor + @FieldDefaults(makeFinal = true) + @Slf4j + static class WaitForExitedState extends AbstractWaitStrategy { + + Predicate predicate; + + @Override + @SneakyThrows + protected void waitUntilReady() { + Unreliables.retryUntilTrue( + 5, + TimeUnit.SECONDS, + () -> { + ContainerState state = waitStrategyTarget.getCurrentContainerInfo().getState(); + + log.debug("Current state: {}", state); + if (!"exited".equalsIgnoreCase(state.getStatus())) { + Thread.sleep(100); + return false; + } + return predicate.test(state); + } + ); + + throw new IllegalStateException("Nope!"); + } + } + + static class HelloWorldContainer extends GenericContainer { + + public HelloWorldContainer(String image) { + super(DockerImageName.parse(image)); + withExposedPorts(8080); + } + } + + static class TcHelloWorldContainer extends GenericContainer { + + public TcHelloWorldContainer(String image) { + super(DockerImageName.parse(image)); + } + + @Override + ContainerDef createContainerDef() { + return new HelloWorldContainerDef(); + } + + class HelloWorldContainerDef extends ContainerDef { + + HelloWorldContainerDef() { + addExposedTcpPort(8080); + } + } + } + + static class HelloWorldLogStrategyContainer extends GenericContainer { + + public HelloWorldLogStrategyContainer(String image) { + super(DockerImageName.parse(image)); + withExposedPorts(8080); + waitingFor(Wait.forLogMessage(".*Starting server on port.*", 2)); + } + } +} diff --git a/core/src/test/java/org/testcontainers/containers/JibTest.java b/core/src/test/java/org/testcontainers/containers/JibTest.java new file mode 100644 index 00000000000..df045052bc3 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/JibTest.java @@ -0,0 +1,88 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.command.InspectImageResponse; +import org.junit.jupiter.api.Test; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.output.OutputFrame.OutputType; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; +import org.testcontainers.jib.JibImage; + +import java.time.Duration; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; + +class JibTest { + + @Test + void buildImage() { + try ( + // jibContainerUsage { + GenericContainer busybox = new GenericContainer<>( + new JibImage( + "busybox:1.35", + jibContainerBuilder -> { + return jibContainerBuilder.setEntrypoint("echo", "Hello World"); + } + ) + ) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy().withTimeout(Duration.ofSeconds(3))) + // } + ) { + busybox.start(); + String logs = busybox.getLogs(OutputType.STDOUT); + assertThat(logs).contains("Hello World"); + } + } + + @Test + void standardLabelsAreAddedWhenUsingJibSetLabels() { + try ( + GenericContainer busybox = new GenericContainer<>( + new JibImage( + "busybox:1.35", + jibContainerBuilder -> { + return jibContainerBuilder + .setEntrypoint("echo", "Hello World") + .setLabels(Collections.singletonMap("foo", "bar")); + } + ) + ) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy().withTimeout(Duration.ofSeconds(3))) + ) { + busybox.start(); + assertImageLabels(busybox); + } + } + + @Test + void standardLabelsAreAddedWhenUsingJibAddLabel() { + try ( + GenericContainer busybox = new GenericContainer<>( + new JibImage( + "busybox:1.35", + jibContainerBuilder -> { + return jibContainerBuilder.setEntrypoint("echo", "Hello World").addLabel("foo", "bar"); + } + ) + ) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy().withTimeout(Duration.ofSeconds(3))) + ) { + busybox.start(); + assertImageLabels(busybox); + } + } + + private static void assertImageLabels(GenericContainer busybox) { + String image = busybox.getContainerInfo().getConfig().getImage(); + InspectImageResponse imageResponse = DockerClientFactory.lazyClient().inspectImageCmd(image).exec(); + assertThat(imageResponse.getConfig().getLabels()) + .containsEntry("foo", "bar") + .containsKeys( + "org.testcontainers", + "org.testcontainers.sessionId", + "org.testcontainers.lang", + "org.testcontainers.version" + ); + } +} diff --git a/core/src/test/java/org/testcontainers/containers/MultiStageBuildTest.java b/core/src/test/java/org/testcontainers/containers/MultiStageBuildTest.java new file mode 100644 index 00000000000..b1ec1c707d5 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/MultiStageBuildTest.java @@ -0,0 +1,44 @@ +package org.testcontainers.containers; + +import org.junit.jupiter.api.Test; +import org.testcontainers.images.builder.ImageFromDockerfile; + +import java.io.IOException; +import java.nio.file.Paths; + +import static org.assertj.core.api.Assertions.assertThat; + +class MultiStageBuildTest { + + @Test + void testDockerMultistageBuild() throws IOException, InterruptedException { + try ( + GenericContainer container = new GenericContainer<>( + new ImageFromDockerfile() + .withDockerfile(Paths.get("src/test/resources/Dockerfile-multistage")) + .withTarget("builder") + ) + .withCommand("/bin/sh", "-c", "sleep 10") + ) { + container.start(); + assertThat(container.execInContainer("pwd").getStdout()).contains("/my-files"); + assertThat(container.execInContainer("ls").getStdout()).contains("hello.txt"); + } + } + + @Test + void shouldBuildMultistageBuildWithBuildImageCmdModifier() throws IOException, InterruptedException { + try ( + GenericContainer container = new GenericContainer<>( + new ImageFromDockerfile() + .withDockerfile(Paths.get("src/test/resources/Dockerfile-multistage")) + .withBuildImageCmdModifier(cmd -> cmd.withTarget("builder")) + ) + .withCommand("/bin/sh", "-c", "sleep 10") + ) { + container.start(); + assertThat(container.execInContainer("pwd").getStdout()).contains("/my-files"); + assertThat(container.execInContainer("ls").getStdout()).contains("hello.txt"); + } + } +} diff --git a/core/src/test/java/org/testcontainers/containers/NetworkTest.java b/core/src/test/java/org/testcontainers/containers/NetworkTest.java new file mode 100644 index 00000000000..675658b353a --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/NetworkTest.java @@ -0,0 +1,113 @@ +package org.testcontainers.containers; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.TestImages; + +import static org.assertj.core.api.Assertions.assertThat; + +class NetworkTest { + + @Nested + class WithRules { + + public Network network = Network.newNetwork(); + + public GenericContainer foo = new GenericContainer<>(TestImages.TINY_IMAGE) + .withNetwork(network) + .withNetworkAliases("foo") + .withCommand("/bin/sh", "-c", "while true ; do printf 'HTTP/1.1 200 OK\\n\\nyay' | nc -l -p 8080; done"); + + public GenericContainer bar = new GenericContainer<>(TestImages.TINY_IMAGE) + .withNetwork(network) + .withCommand("top"); + + void testNetworkSupport() throws Exception { + foo.start(); + bar.start(); + String response = bar.execInContainer("wget", "-O", "-", "http://foo:8080").getStdout(); + assertThat(response).as("received response").isEqualTo("yay"); + } + } + + @Nested + class WithoutRules { + + @Test + void testNetworkSupport() throws Exception { + // useCustomNetwork { + try ( + Network network = Network.newNetwork(); + GenericContainer foo = new GenericContainer<>(TestImages.TINY_IMAGE) + .withNetwork(network) + .withNetworkAliases("foo") + .withCommand( + "/bin/sh", + "-c", + "while true ; do printf 'HTTP/1.1 200 OK\\n\\nyay' | nc -l -p 8080; done" + ); + GenericContainer bar = new GenericContainer<>(TestImages.TINY_IMAGE) + .withNetwork(network) + .withCommand("top") + ) { + foo.start(); + bar.start(); + + String response = bar.execInContainer("wget", "-O", "-", "http://foo:8080").getStdout(); + assertThat(response).as("received response").isEqualTo("yay"); + } + // } + } + + @Test + void testBuilder() { + try (Network network = Network.builder().driver("macvlan").build()) { + String id = network.getId(); + assertThat( + DockerClientFactory.instance().client().inspectNetworkCmd().withNetworkId(id).exec().getDriver() + ) + .as("Flag is set") + .isEqualTo("macvlan"); + } + } + + @Test + void testModifiers() { + try ( + Network network = Network.builder().createNetworkCmdModifier(cmd -> cmd.withDriver("macvlan")).build() + ) { + String id = network.getId(); + assertThat( + DockerClientFactory.instance().client().inspectNetworkCmd().withNetworkId(id).exec().getDriver() + ) + .as("Flag is set") + .isEqualTo("macvlan"); + } + } + + @Test + void testReusability() { + try (Network network = Network.newNetwork()) { + String firstId = network.getId(); + assertThat(DockerClientFactory.instance().client().inspectNetworkCmd().withNetworkId(firstId).exec()) + .as("Network exists") + .isNotNull(); + + network.close(); + + assertThat( + DockerClientFactory + .instance() + .client() + .inspectNetworkCmd() + .withNetworkId(network.getId()) + .exec() + .getId() + ) + .as("New network created") + .isNotEqualTo(firstId); + } + } + } +} diff --git a/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileBean.java b/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileBean.java new file mode 100644 index 00000000000..8edca5a580a --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileBean.java @@ -0,0 +1,10 @@ +package org.testcontainers.containers; + +public class ParsedDockerComposeFileBean { + + public String foo; + + public ParsedDockerComposeFileBean(String foo) { + this.foo = foo; + } +} diff --git a/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileValidationTest.java b/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileValidationTest.java new file mode 100644 index 00000000000..36126df6a38 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileValidationTest.java @@ -0,0 +1,170 @@ +package org.testcontainers.containers; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Sets; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.PrintWriter; +import java.nio.file.Path; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.entry; + +class ParsedDockerComposeFileValidationTest { + + @TempDir + public Path temporaryFolder; + + @Test + void shouldValidate() { + File file = new File("src/test/resources/docker-compose-container-name-v1.yml"); + assertThatThrownBy(() -> { + new ParsedDockerComposeFile(file); + }) + .hasMessageContaining(file.getAbsolutePath()) + .hasMessageContaining("'container_name' property set for service 'redis'"); + } + + @Test + void shouldRejectContainerNameV1() { + assertThatThrownBy(() -> { + new ParsedDockerComposeFile(ImmutableMap.of("redis", ImmutableMap.of("container_name", "redis"))); + }) + .hasMessageContaining("'container_name' property set for service 'redis'"); + } + + @Test + void shouldRejectContainerNameV2() { + assertThatThrownBy(() -> { + new ParsedDockerComposeFile( + ImmutableMap.of( + "version", + "2", + "services", + ImmutableMap.of("redis", ImmutableMap.of("container_name", "redis")) + ) + ); + }) + .hasMessageContaining("'container_name' property set for service 'redis'"); + } + + @Test + void shouldIgnoreUnknownStructure() { + // Everything is a list + new ParsedDockerComposeFile(Collections.emptyMap()); + + // services is not a map but List + new ParsedDockerComposeFile(ImmutableMap.of("version", "2", "services", Collections.emptyList())); + + // services is not a collection + new ParsedDockerComposeFile(ImmutableMap.of("version", "2", "services", true)); + + // no services while version is defined + new ParsedDockerComposeFile(ImmutableMap.of("version", "9000")); + } + + @Test + @SneakyThrows + void shouldRejectDeserializationOfArbitraryClasses() { + // Reject deserialization gadget chain attacks: https://nvd.nist.gov/vuln/detail/CVE-2022-1471 + // https://raw.githubusercontent.com/mbechler/marshalsec/master/marshalsec.pdf + + File file = new File("src/test/resources/docker-compose-deserialization.yml"); + + // ParsedDockerComposeFile should reject deserialization of ParsedDockerComposeFileBean + assertThatThrownBy(() -> { + new ParsedDockerComposeFile(file); + }) + .hasMessageContaining(file.getAbsolutePath()) + .hasMessageContaining("Unable to parse YAML file"); + } + + @Test + void shouldObtainImageNamesV1() { + File file = new File("src/test/resources/docker-compose-imagename-parsing-v1.yml"); + ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(file); + assertThat(parsedFile.getServiceNameToImageNames()) + .as("all defined images are found") + .contains( + entry("mysql", Sets.newHashSet("mysql")), + entry("redis", Sets.newHashSet("redis")), + entry("custom", Sets.newHashSet("postgres")) + ); // redis, mysql from compose file, postgres from Dockerfile build + } + + @Test + void shouldObtainImageNamesV2() { + File file = new File("src/test/resources/docker-compose-imagename-parsing-v2.yml"); + ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(file); + assertThat(parsedFile.getServiceNameToImageNames()) + .as("all defined images are found") + .contains( + entry("mysql", Sets.newHashSet("mysql")), + entry("redis", Sets.newHashSet("redis")), + entry("custom", Sets.newHashSet("postgres")) + ); + } + + @Test + void shouldObtainImageNamesV2WithNoVersionTag() { + File file = new File("src/test/resources/docker-compose-imagename-parsing-v2-no-version.yml"); + ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(file); + assertThat(parsedFile.getServiceNameToImageNames()) + .as("all defined images are found") + .contains( + entry("mysql", Sets.newHashSet("mysql")), + entry("redis", Sets.newHashSet("redis")), + entry("custom", Sets.newHashSet("postgres")) + ); + } + + @Test + void shouldObtainImageFromDockerfileBuild() { + File file = new File("src/test/resources/docker-compose-imagename-parsing-dockerfile.yml"); + ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(file); + assertThat(parsedFile.getServiceNameToImageNames()) + .as("all defined images are found") + .contains( + entry("mysql", Sets.newHashSet("mysql")), + entry("redis", Sets.newHashSet("redis")), + entry("custom", Sets.newHashSet("alpine:3.17")) + ); // r/ redis, mysql from compose file, alpine:3.17 from Dockerfile build + } + + @Test + void shouldObtainImageFromDockerfileBuildWithContext() { + File file = new File("src/test/resources/docker-compose-imagename-parsing-dockerfile-with-context.yml"); + ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(file); + assertThat(parsedFile.getServiceNameToImageNames()) + .as("all defined images are found") + .contains( + entry("mysql", Sets.newHashSet("mysql")), + entry("redis", Sets.newHashSet("redis")), + entry("custom", Sets.newHashSet("alpine:3.17")) + ); // redis, mysql from compose file, alpine:3.17 from Dockerfile build + } + + @Test + void shouldSupportALotOfAliases() throws Exception { + File file = temporaryFolder.resolve("tmp-docker-compose.yml").toFile(); + try (PrintWriter writer = new PrintWriter(file)) { + writer.println("x-entry: &entry"); + writer.println(" key: value"); + writer.println(); + writer.println("services:"); + for (int i = 0; i < 1_000; i++) { + writer.println(" service" + i + ":"); + writer.println(" image: busybox"); + writer.println(" environment:"); + writer.println(" <<: *entry"); + } + } + assertThatNoException().isThrownBy(() -> new ParsedDockerComposeFile(file)); + } +} diff --git a/core/src/test/java/org/testcontainers/containers/ReusabilityUnitTests.java b/core/src/test/java/org/testcontainers/containers/ReusabilityUnitTests.java new file mode 100644 index 00000000000..2c70a3f894f --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/ReusabilityUnitTests.java @@ -0,0 +1,586 @@ +package org.testcontainers.containers; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.CreateContainerCmd; +import com.github.dockerjava.api.command.CreateContainerResponse; +import com.github.dockerjava.api.command.InspectContainerCmd; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.command.ListContainersCmd; +import com.github.dockerjava.api.command.StartContainerCmd; +import com.github.dockerjava.api.model.Container; +import com.github.dockerjava.core.command.CreateContainerCmdImpl; +import com.github.dockerjava.core.command.InspectContainerCmdImpl; +import com.github.dockerjava.core.command.ListContainersCmdImpl; +import com.github.dockerjava.core.command.StartContainerCmdImpl; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Answers; +import org.mockito.Mockito; +import org.mockito.stubbing.Answer; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.TestImages; +import org.testcontainers.containers.startupcheck.StartupCheckStrategy; +import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; +import org.testcontainers.utility.MockTestcontainersConfigurationExtension; +import org.testcontainers.utility.MountableFile; +import org.testcontainers.utility.TestcontainersConfiguration; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +class ReusabilityUnitTests { + + @Nested + class CanBeReusedTest { + + public static Object[][] data() { + return new Object[][] { + { "generic", new GenericContainer<>(TestImages.TINY_IMAGE), true }, + { "anonymous generic", new GenericContainer(TestImages.TINY_IMAGE) {}, true }, + { "custom", new CustomContainer(), true }, + { "anonymous custom", new CustomContainer() {}, true }, + { "custom with containerIsCreated", new CustomContainerWithContainerIsCreated(), false }, + }; + } + + @ParameterizedTest(name = "{0}") + @MethodSource("data") + void shouldBeReusable(String name, GenericContainer container, boolean reusable) { + if (reusable) { + assertThat(container.canBeReused()).as("Is reusable").isTrue(); + } else { + assertThat(container.canBeReused()).as("Is not reusable").isFalse(); + } + } + + static class CustomContainer extends GenericContainer { + + CustomContainer() { + super(TestImages.TINY_IMAGE); + } + } + + static class CustomContainerWithContainerIsCreated + extends GenericContainer { + + CustomContainerWithContainerIsCreated() { + super(TestImages.TINY_IMAGE); + } + + @Override + protected void containerIsCreated(String containerId) { + super.containerIsCreated(containerId); + } + } + } + + @Nested + class HooksTest extends AbstractReusabilityTest { + + List script = new ArrayList<>(); + + GenericContainer container = makeReusable( + new GenericContainer(TestImages.TINY_IMAGE) { + @Override + protected boolean canBeReused() { + // Because we override "containerIsCreated" + return true; + } + + @Override + protected void containerIsCreated(String containerId) { + script.add("containerIsCreated"); + } + + @Override + protected void containerIsStarting(InspectContainerResponse containerInfo, boolean reused) { + script.add("containerIsStarting(reused=" + reused + ")"); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo, boolean reused) { + script.add("containerIsStarted(reused=" + reused + ")"); + } + } + ); + + @Test + void shouldSetLabelsIfEnvironmentDoesNotSupportReuse() { + Mockito.doReturn(false).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); + + String containerId = randomContainerId(); + when(client.createContainerCmd(any())).then(createContainerAnswer(containerId)); + when(client.listContainersCmd()).then(listContainersAnswer()); + when(client.startContainerCmd(containerId)).then(startContainerAnswer()); + when(client.inspectContainerCmd(containerId)).then(inspectContainerAnswer()); + + container.start(); + assertThat(script) + .containsExactly( + "containerIsCreated", + "containerIsStarting(reused=false)", + "containerIsStarted(reused=false)" + ); + } + + @Test + void shouldCallHookIfReused() { + Mockito.doReturn(true).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); + String containerId = randomContainerId(); + when(client.createContainerCmd(any())).then(createContainerAnswer(containerId)); + String existingContainerId = randomContainerId(); + when(client.listContainersCmd()).then(listContainersAnswer(existingContainerId)); + when(client.inspectContainerCmd(existingContainerId)).then(inspectContainerAnswer()); + + container.start(); + assertThat(script).containsExactly("containerIsStarting(reused=true)", "containerIsStarted(reused=true)"); + } + + @Test + void shouldNotCallHookIfNotReused() { + String containerId = randomContainerId(); + when(client.createContainerCmd(any())).then(createContainerAnswer(containerId)); + when(client.listContainersCmd()).then(listContainersAnswer()); + when(client.startContainerCmd(containerId)).then(startContainerAnswer()); + when(client.inspectContainerCmd(containerId)).then(inspectContainerAnswer()); + + container.start(); + assertThat(script) + .containsExactly( + "containerIsCreated", + "containerIsStarting(reused=false)", + "containerIsStarted(reused=false)" + ); + } + } + + @Nested + class HashTest extends AbstractReusabilityTest { + + protected GenericContainer container = makeReusable( + new GenericContainer(TestImages.TINY_IMAGE) { + @Override + public void copyFileToContainer(MountableFile mountableFile, String containerPath) { + // NOOP + } + } + ); + + @Test + void shouldStartIfListReturnsEmpty() { + String containerId = randomContainerId(); + when(client.createContainerCmd(any())).then(createContainerAnswer(containerId)); + when(client.listContainersCmd()).then(listContainersAnswer()); + when(client.startContainerCmd(containerId)).then(startContainerAnswer()); + when(client.inspectContainerCmd(containerId)).then(inspectContainerAnswer()); + + container.start(); + + Mockito.verify(client, Mockito.atLeastOnce()).startContainerCmd(containerId); + } + + @Test + void shouldReuseIfListReturnsID() { + Mockito.doReturn(true).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); + String containerId = randomContainerId(); + when(client.createContainerCmd(any())).then(createContainerAnswer(containerId)); + String existingContainerId = randomContainerId(); + when(client.listContainersCmd()).then(listContainersAnswer(existingContainerId)); + when(client.inspectContainerCmd(existingContainerId)).then(inspectContainerAnswer()); + + container.start(); + + Mockito.verify(client, Mockito.never()).startContainerCmd(containerId); + Mockito.verify(client, Mockito.never()).startContainerCmd(existingContainerId); + } + + @Test + void shouldSetLabelsIfEnvironmentDoesNotSupportReuse() { + Mockito.doReturn(false).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); + AtomicReference commandRef = new AtomicReference<>(); + String containerId = randomContainerId(); + when(client.createContainerCmd(any())).then(createContainerAnswer(containerId, commandRef::set)); + when(client.startContainerCmd(containerId)).then(startContainerAnswer()); + when(client.inspectContainerCmd(containerId)).then(inspectContainerAnswer()); + + container.start(); + + assertThat(commandRef) + .isNotNull() + .satisfies(command -> { + assertThat(command.get().getLabels()) + .containsKeys(DockerClientFactory.TESTCONTAINERS_SESSION_ID_LABEL); + }); + } + + @Test + void shouldSetCopiedFilesHashLabel() { + Mockito.doReturn(true).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); + AtomicReference commandRef = new AtomicReference<>(); + String containerId = randomContainerId(); + when(client.createContainerCmd(any())).then(createContainerAnswer(containerId, commandRef::set)); + when(client.listContainersCmd()).then(listContainersAnswer()); + when(client.startContainerCmd(containerId)).then(startContainerAnswer()); + when(client.inspectContainerCmd(containerId)).then(inspectContainerAnswer()); + + container.start(); + + assertThat(commandRef).isNotNull(); + assertThat(commandRef.get().getLabels()).containsKeys(GenericContainer.COPIED_FILES_HASH_LABEL); + } + + @Test + void shouldHashCopiedFiles() { + Mockito.doReturn(true).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); + AtomicReference commandRef = new AtomicReference<>(); + String containerId = randomContainerId(); + when(client.createContainerCmd(any())).then(createContainerAnswer(containerId, commandRef::set)); + when(client.listContainersCmd()).then(listContainersAnswer()); + when(client.startContainerCmd(containerId)).then(startContainerAnswer()); + when(client.inspectContainerCmd(containerId)).then(inspectContainerAnswer()); + + container.start(); + + assertThat(commandRef).isNotNull(); + + Map labels = commandRef.get().getLabels(); + assertThat(labels).containsKeys(GenericContainer.COPIED_FILES_HASH_LABEL); + + String oldHash = labels.get(GenericContainer.COPIED_FILES_HASH_LABEL); + + // Simulate stop + container.containerId = null; + + container.withCopyFileToContainer( + MountableFile.forClasspathResource("test_copy_to_container.txt"), + "/foo/bar" + ); + container.start(); + + assertThat(commandRef.get().getLabels()) + .hasEntrySatisfying( + GenericContainer.COPIED_FILES_HASH_LABEL, + newHash -> { + assertThat(newHash).as("new hash").isNotEqualTo(oldHash); + } + ); + } + } + + @Nested + @ParameterizedClass + @MethodSource("strategies") + class CopyFilesHashTest { + + private final TestStrategy strategy; + + interface TestStrategy { + void withCopyFileToContainer(MountableFile mountableFile, String path); + + void clear(); + } + + private static class MountableFileTestStrategy implements TestStrategy { + + private final GenericContainer container; + + private MountableFileTestStrategy(GenericContainer container) { + this.container = container; + } + + @Override + public void withCopyFileToContainer(MountableFile mountableFile, String path) { + container.withCopyFileToContainer(mountableFile, path); + } + + @Override + public void clear() { + container.getCopyToFileContainerPathMap().clear(); + } + } + + private static class TransferableTestStrategy implements TestStrategy { + + private final GenericContainer container; + + private TransferableTestStrategy(GenericContainer container) { + this.container = container; + } + + @Override + public void withCopyFileToContainer(MountableFile mountableFile, String path) { + container.withCopyToContainer(mountableFile, path); + } + + @Override + public void clear() { + container.getCopyToTransferableContainerPathMap().clear(); + } + } + + public static List, TestStrategy>> strategies() { + return Arrays.asList(MountableFileTestStrategy::new, TransferableTestStrategy::new); + } + + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE); + + public CopyFilesHashTest(Function, TestStrategy> strategyFactory) { + this.strategy = strategyFactory.apply(container); + } + + @Test + void empty() { + assertThat(container.hashCopiedFiles()).isNotNull(); + } + + @Test + void oneFile() { + long emptyHash = container.hashCopiedFiles().getValue(); + + strategy.withCopyFileToContainer( + MountableFile.forClasspathResource("test_copy_to_container.txt"), + "/foo/bar" + ); + + assertThat(container.hashCopiedFiles().getValue()).isNotEqualTo(emptyHash); + } + + @Test + void differentPath() { + MountableFile mountableFile = MountableFile.forClasspathResource("test_copy_to_container.txt"); + strategy.withCopyFileToContainer(mountableFile, "/foo/bar"); + + long hash1 = container.hashCopiedFiles().getValue(); + + strategy.clear(); + + strategy.withCopyFileToContainer(mountableFile, "/foo/baz"); + + assertThat(container.hashCopiedFiles().getValue()).isNotEqualTo(hash1); + } + + @Test + void detectsChangesInFile() throws Exception { + Path path = File.createTempFile("reusable_test", ".txt").toPath(); + MountableFile mountableFile = MountableFile.forHostPath(path); + strategy.withCopyFileToContainer(mountableFile, "/foo/bar"); + + long hash1 = container.hashCopiedFiles().getValue(); + + Files.write(path, UUID.randomUUID().toString().getBytes()); + + assertThat(container.hashCopiedFiles().getValue()).isNotEqualTo(hash1); + } + + @Test + void multipleFiles() { + strategy.withCopyFileToContainer( + MountableFile.forClasspathResource("test_copy_to_container.txt"), + "/foo/bar" + ); + long hash1 = container.hashCopiedFiles().getValue(); + + strategy.withCopyFileToContainer( + MountableFile.forClasspathResource("mappable-resource/test-resource.txt"), + "/foo/baz" + ); + + assertThat(container.hashCopiedFiles().getValue()).isNotEqualTo(hash1); + } + + @Test + void folder() throws Exception { + long emptyHash = container.hashCopiedFiles().getValue(); + + Path tempDirectory = Files.createTempDirectory("reusable_test"); + MountableFile mountableFile = MountableFile.forHostPath(tempDirectory); + strategy.withCopyFileToContainer(mountableFile, "/foo/bar/"); + + assertThat(container.hashCopiedFiles().getValue()).isNotEqualTo(emptyHash); + } + + @Test + void changesInFolder() throws Exception { + Path tempDirectory = Files.createTempDirectory("reusable_test"); + MountableFile mountableFile = MountableFile.forHostPath(tempDirectory); + assertThat(new File(mountableFile.getResolvedPath())).isDirectory(); + strategy.withCopyFileToContainer(mountableFile, "/foo/bar/"); + + long hash1 = container.hashCopiedFiles().getValue(); + + Path fileInFolder = Files.createFile( + // Create file in the sub-folder + Files.createDirectory(tempDirectory.resolve("sub")).resolve("test.txt") + ); + assertThat(fileInFolder).exists(); + Files.write(fileInFolder, UUID.randomUUID().toString().getBytes()); + + assertThat(container.hashCopiedFiles().getValue()).isNotEqualTo(hash1); + } + + @Test + void folderAndFile() throws Exception { + Path tempDirectory = Files.createTempDirectory("reusable_test"); + MountableFile mountableFile = MountableFile.forHostPath(tempDirectory); + assertThat(new File(mountableFile.getResolvedPath())).isDirectory(); + strategy.withCopyFileToContainer(mountableFile, "/foo/bar/"); + + long hash1 = container.hashCopiedFiles().getValue(); + + strategy.withCopyFileToContainer( + MountableFile.forClasspathResource("test_copy_to_container.txt"), + "/foo/baz" + ); + + assertThat(container.hashCopiedFiles().getValue()).isNotEqualTo(hash1); + } + + @Test + void filePermissions() throws Exception { + Path path = File.createTempFile("reusable_test", ".txt").toPath(); + path.toFile().setExecutable(false); + MountableFile mountableFile = MountableFile.forHostPath(path); + strategy.withCopyFileToContainer(mountableFile, "/foo/bar"); + + long hash1 = container.hashCopiedFiles().getValue(); + + assumeThat(path.toFile().canExecute()).isFalse(); + path.toFile().setExecutable(true); + + assertThat(container.hashCopiedFiles().getValue()).isNotEqualTo(hash1); + } + + @Test + void folderPermissions() throws Exception { + Path tempDirectory = Files.createTempDirectory("reusable_test"); + MountableFile mountableFile = MountableFile.forHostPath(tempDirectory); + assertThat(new File(mountableFile.getResolvedPath())).isDirectory(); + Path subDir = Files.createDirectory(tempDirectory.resolve("sub")); + subDir.toFile().setWritable(false); + assumeThat(subDir.toFile().canWrite()).isFalse(); + strategy.withCopyFileToContainer(mountableFile, "/foo/bar/"); + + long hash1 = container.hashCopiedFiles().getValue(); + + subDir.toFile().setWritable(true); + assumeThat(subDir.toFile()).canWrite(); + + assertThat(container.hashCopiedFiles().getValue()).isNotEqualTo(hash1); + } + } + + @ExtendWith(MockTestcontainersConfigurationExtension.class) + public abstract static class AbstractReusabilityTest { + + protected DockerClient client = Mockito.mock(DockerClient.class); + + protected > T makeReusable(T container) { + container.dockerClient = client; + container.withNetworkMode("none"); // to disable the port forwarding + container.withStartupCheckStrategy( + new StartupCheckStrategy() { + @Override + public boolean waitUntilStartupSuccessful(DockerClient dockerClient, String containerId) { + // Skip DockerClient rate limiter + return true; + } + + @Override + public StartupStatus checkStartupState(DockerClient dockerClient, String containerId) { + return StartupStatus.SUCCESSFUL; + } + } + ); + container.waitingFor( + new AbstractWaitStrategy() { + @Override + protected void waitUntilReady() {} + } + ); + container.withReuse(true); + return container; + } + + protected String randomContainerId() { + return UUID.randomUUID().toString(); + } + + protected Answer listContainersAnswer(String... ids) { + return invocation -> { + ListContainersCmd.Exec exec = command -> { + return new ObjectMapper() + .convertValue( + Stream.of(ids).map(id -> Collections.singletonMap("Id", id)).collect(Collectors.toList()), + new TypeReference>() {} + ); + }; + return new ListContainersCmdImpl(exec); + }; + } + + protected Answer createContainerAnswer(String containerId) { + return createContainerAnswer(containerId, command -> {}); + } + + protected Answer createContainerAnswer( + String containerId, + Consumer cmdConsumer + ) { + return invocation -> { + CreateContainerCmd.Exec exec = command -> { + cmdConsumer.accept(command); + CreateContainerResponse response = new CreateContainerResponse(); + response.setId(containerId); + return response; + }; + return new CreateContainerCmdImpl(exec, null, "image:latest"); + }; + } + + protected Answer startContainerAnswer() { + return invocation -> { + StartContainerCmd.Exec exec = command -> { + return null; + }; + return new StartContainerCmdImpl(exec, invocation.getArgument(0)); + }; + } + + protected Answer inspectContainerAnswer() { + return invocation -> { + InspectContainerCmd.Exec exec = command -> { + InspectContainerResponse stubResponse = Mockito.mock( + InspectContainerResponse.class, + Answers.RETURNS_DEEP_STUBS + ); + when(stubResponse.getNetworkSettings().getPorts().getBindings()).thenReturn(Collections.emptyMap()); + return stubResponse; + }; + return new InspectContainerCmdImpl(exec, invocation.getArgument(0)); + }; + } + } +} diff --git a/core/src/test/java/org/testcontainers/containers/output/ContainerLogsTest.java b/core/src/test/java/org/testcontainers/containers/output/ContainerLogsTest.java new file mode 100644 index 00000000000..f742b29890b --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/output/ContainerLogsTest.java @@ -0,0 +1,82 @@ +package org.testcontainers.containers.output; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.testcontainers.TestImages; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContainerLogsTest { + + @Test + @Disabled("fails due to the timing of the shell's decision to flush") + void getLogsReturnsAllLogsToDate() { + try (GenericContainer container = shortLivedContainer()) { + container.start(); + + final String logs = container.getLogs(); + assertThat(logs).as("stdout and stderr are reflected in the returned logs").isEqualTo("stdout\nstderr"); + } + } + + @Test + void getLogsContainsBothOutputTypes() { + try (GenericContainer container = shortLivedContainer()) { + container.start(); + + // docsGetAllLogs { + final String logs = container.getLogs(); + // } + assertThat(logs).as("stdout is reflected in the returned logs").contains("stdout"); + assertThat(logs).as("stderr is reflected in the returned logs").contains("stderr"); + } + } + + @Test + void getLogsReturnsStdOutToDate() { + try (GenericContainer container = shortLivedContainer()) { + container.start(); + + // docsGetStdOut { + final String logs = container.getLogs(OutputFrame.OutputType.STDOUT); + // } + assertThat(logs).as("stdout is reflected in the returned logs").contains("stdout"); + } + } + + @Test + void getLogsReturnsStdErrToDate() { + try (GenericContainer container = shortLivedContainer()) { + container.start(); + + // docsGetStdErr { + final String logs = container.getLogs(OutputFrame.OutputType.STDERR); + // } + assertThat(logs).as("stderr is reflected in the returned logs").contains("stderr"); + } + } + + @Test + void getLogsForLongRunningContainer() throws InterruptedException { + try (GenericContainer container = longRunningContainer()) { + container.start(); + + Thread.sleep(1000L); + + final String logs = container.getLogs(OutputFrame.OutputType.STDOUT); + assertThat(logs).as("stdout is reflected in the returned logs for a running container").contains("seq=0"); + } + } + + private static GenericContainer shortLivedContainer() { + return new GenericContainer<>(TestImages.ALPINE_IMAGE) + .withCommand("/bin/sh", "-c", "echo -n 'stdout' && echo -n 'stderr' 1>&2") + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()); + } + + private static GenericContainer longRunningContainer() { + return new GenericContainer<>(TestImages.ALPINE_IMAGE).withCommand("ping -c 100 127.0.0.1"); + } +} diff --git a/core/src/test/java/org/testcontainers/containers/output/FrameConsumerResultCallbackTest.java b/core/src/test/java/org/testcontainers/containers/output/FrameConsumerResultCallbackTest.java new file mode 100644 index 00000000000..33ae9fd17f5 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/output/FrameConsumerResultCallbackTest.java @@ -0,0 +1,236 @@ +package org.testcontainers.containers.output; + +import com.github.dockerjava.api.model.Frame; +import com.github.dockerjava.api.model.StreamType; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +class FrameConsumerResultCallbackTest { + + private static final String FRAME_PAYLOAD = + "\u001B[0;32mТест1\u001B[0m\n\u001B[1;33mTest2\u001B[0m\n\u001B[0;31mTest3\u001B[0m"; + + private static final String LOG_RESULT = "Тест1\nTest2\nTest3"; + + @Test + void passStderrFrameWithoutColors() throws IOException { + FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); + ToStringConsumer consumer = new ToStringConsumer(); + callback.addConsumer(OutputFrame.OutputType.STDERR, consumer); + callback.onNext(new Frame(StreamType.STDERR, FRAME_PAYLOAD.getBytes())); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(LOG_RESULT); + } + + @Test + void passStderrFrameWithColors() throws IOException { + FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); + ToStringConsumer consumer = new ToStringConsumer().withRemoveAnsiCodes(false); + callback.addConsumer(OutputFrame.OutputType.STDERR, consumer); + callback.onNext(new Frame(StreamType.STDERR, FRAME_PAYLOAD.getBytes())); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(FRAME_PAYLOAD); + } + + @Test + void passStdoutFrameWithoutColors() throws IOException { + FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); + ToStringConsumer consumer = new ToStringConsumer(); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); + callback.onNext(new Frame(StreamType.STDOUT, FRAME_PAYLOAD.getBytes())); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(LOG_RESULT); + } + + @Test + void passStdoutFrameWithColors() throws IOException { + FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); + ToStringConsumer consumer = new ToStringConsumer().withRemoveAnsiCodes(false); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); + callback.onNext(new Frame(StreamType.STDOUT, FRAME_PAYLOAD.getBytes())); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(FRAME_PAYLOAD); + } + + @Test + void basicConsumer() throws IOException { + FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); + BasicConsumer consumer = new BasicConsumer(); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); + callback.onNext(new Frame(StreamType.STDOUT, FRAME_PAYLOAD.getBytes())); + callback.close(); + assertThat(consumer.toString()).isEqualTo(LOG_RESULT); + } + + @Test + void passStdoutNull() throws IOException { + FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); + ToStringConsumer consumer = new ToStringConsumer().withRemoveAnsiCodes(false); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); + callback.onNext(new Frame(StreamType.STDOUT, null)); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(""); + } + + @Test + void passStdoutEmptyLine() throws IOException { + String payload = ""; + FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); + ToStringConsumer consumer = new ToStringConsumer().withRemoveAnsiCodes(false); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); + callback.onNext(new Frame(StreamType.STDOUT, payload.getBytes())); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(payload); + } + + @Test + void passStdoutSingleLine() throws IOException { + String payload = "Test"; + FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); + ToStringConsumer consumer = new ToStringConsumer().withRemoveAnsiCodes(false); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); + callback.onNext(new Frame(StreamType.STDOUT, payload.getBytes())); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(payload); + } + + @Test + void passStdoutSingleLineWithNewline() throws IOException { + String payload = "Test\n"; + FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); + ToStringConsumer consumer = new ToStringConsumer().withRemoveAnsiCodes(false); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); + callback.onNext(new Frame(StreamType.STDOUT, payload.getBytes())); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(payload); + } + + @Test + void passRawFrameWithoutColors() throws TimeoutException, IOException { + FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); + WaitingConsumer waitConsumer = new WaitingConsumer(); + callback.addConsumer(OutputFrame.OutputType.STDOUT, waitConsumer); + callback.onNext(new Frame(StreamType.RAW, FRAME_PAYLOAD.getBytes())); + waitConsumer.waitUntil( + frame -> frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().equals("Test2\n"), + 1, + TimeUnit.SECONDS + ); + waitConsumer.waitUntil( + frame -> frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().equals("Тест1\n"), + 1, + TimeUnit.SECONDS + ); + Exception exception = null; + try { + waitConsumer.waitUntil( + frame -> frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().equals("Test3"), + 1, + TimeUnit.SECONDS + ); + } catch (Exception e) { + exception = e; + } + assertThat(exception instanceof TimeoutException).isTrue(); + callback.close(); + waitConsumer.waitUntil( + frame -> frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().equals("Test3"), + 1, + TimeUnit.SECONDS + ); + } + + @Test + void passRawFrameWithColors() throws TimeoutException, IOException { + FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); + WaitingConsumer waitConsumer = new WaitingConsumer().withRemoveAnsiCodes(false); + callback.addConsumer(OutputFrame.OutputType.STDOUT, waitConsumer); + callback.onNext(new Frame(StreamType.RAW, FRAME_PAYLOAD.getBytes())); + waitConsumer.waitUntil( + frame -> { + return ( + frame.getType() == OutputFrame.OutputType.STDOUT && + frame.getUtf8String().equals("\u001B[1;33mTest2\u001B[0m\n") + ); + }, + 1, + TimeUnit.SECONDS + ); + waitConsumer.waitUntil( + frame -> { + return ( + frame.getType() == OutputFrame.OutputType.STDOUT && + frame.getUtf8String().equals("\u001B[0;32mТест1\u001B[0m\n") + ); + }, + 1, + TimeUnit.SECONDS + ); + Exception exception = null; + try { + waitConsumer.waitUntil( + frame -> { + return ( + frame.getType() == OutputFrame.OutputType.STDOUT && + frame.getUtf8String().equals("\u001B[0;31mTest3\u001B[0m") + ); + }, + 1, + TimeUnit.SECONDS + ); + } catch (Exception e) { + exception = e; + } + assertThat(exception instanceof TimeoutException).isTrue(); + callback.close(); + waitConsumer.waitUntil( + frame -> { + return ( + frame.getType() == OutputFrame.OutputType.STDOUT && + frame.getUtf8String().equals("\u001B[0;31mTest3\u001B[0m") + ); + }, + 1, + TimeUnit.SECONDS + ); + } + + @Test + void reconstructBreakedUnicode() throws IOException { + String payload = "Тест"; + byte[] payloadBytes = payload.getBytes(StandardCharsets.UTF_8); + byte[] bytes1 = new byte[(int) (payloadBytes.length * 0.6)]; + byte[] bytes2 = new byte[payloadBytes.length - bytes1.length]; + System.arraycopy(payloadBytes, 0, bytes1, 0, bytes1.length); + System.arraycopy(payloadBytes, bytes1.length, bytes2, 0, bytes2.length); + FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); + ToStringConsumer consumer = new ToStringConsumer().withRemoveAnsiCodes(false); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); + callback.onNext(new Frame(StreamType.RAW, bytes1)); + callback.onNext(new Frame(StreamType.RAW, bytes2)); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(payload); + } + + private static class BasicConsumer implements Consumer { + + private StringBuilder input = new StringBuilder(); + + @Override + public void accept(OutputFrame outputFrame) { + input.append(outputFrame.getUtf8String()); + } + + @Override + public String toString() { + return input.toString(); + } + } +} diff --git a/core/src/test/java/org/testcontainers/containers/output/ToStringConsumerTest.java b/core/src/test/java/org/testcontainers/containers/output/ToStringConsumerTest.java new file mode 100644 index 00000000000..45472771168 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/output/ToStringConsumerTest.java @@ -0,0 +1,79 @@ +package org.testcontainers.containers.output; + +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.containers.Container.ExecResult; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; + +import static org.assertj.core.api.Assertions.assertThat; + +class ToStringConsumerTest { + + private static final String LARGE_PAYLOAD; + + static { + StringBuilder builder = new StringBuilder(10_003 * 10); + for (int i = 0; i < 10; i++) { + builder.append(' ').append(i).append(RandomStringUtils.randomAlphabetic(10000)); + } + LARGE_PAYLOAD = builder.toString(); + assertThat(LARGE_PAYLOAD).doesNotContain("\n"); + } + + @Test + void newlines_are_not_added_to_exec_output() throws Exception { + try (GenericContainer container = new GenericContainer<>("alpine:3.17")) { + container.withCommand("sleep", "2m"); + container.start(); + + ExecResult build = container.execInContainer("echo", "-n", LARGE_PAYLOAD); + assertThat(build.getStdout()).doesNotContain("\n").isEqualTo(LARGE_PAYLOAD); + } + } + + @Test + @Timeout(60) + void newlines_are_not_added_to_exec_output_with_tty() throws Exception { + try (GenericContainer container = new GenericContainer<>("alpine:3.17")) { + container.withCreateContainerCmdModifier(cmd -> { + cmd.withAttachStdin(true).withStdinOpen(true).withTty(true); + }); + container.withCommand("sleep", "2m"); + container.start(); + + ExecResult build = container.execInContainer("echo", "-n", LARGE_PAYLOAD); + assertThat(build.getStdout()).isEqualTo(LARGE_PAYLOAD).doesNotContain("\n"); + } + } + + @Test + void newlines_are_not_added_to_container_output() { + try (GenericContainer container = new GenericContainer<>("alpine:3.17")) { + container.withCommand("echo", "-n", LARGE_PAYLOAD); + container.setStartupCheckStrategy(new OneShotStartupCheckStrategy()); + container.start(); + + container.getDockerClient().waitContainerCmd(container.getContainerId()).start().awaitStatusCode(); + + assertThat(container.getLogs()).isEqualTo(LARGE_PAYLOAD).doesNotContain("\n"); + } + } + + @Test + void newlines_are_not_added_to_container_output_with_tty() { + try (GenericContainer container = new GenericContainer<>("alpine:3.17")) { + container.withCreateContainerCmdModifier(cmd -> { + cmd.withTty(true); + }); + container.withCommand("echo", "-n", LARGE_PAYLOAD); + container.setStartupCheckStrategy(new OneShotStartupCheckStrategy()); + container.start(); + + container.getDockerClient().waitContainerCmd(container.getContainerId()).start().awaitStatusCode(); + + assertThat(container.getLogs()).isEqualTo(LARGE_PAYLOAD).doesNotContain("\n"); + } + } +} diff --git a/core/src/test/java/org/testcontainers/containers/startupcheck/IsRunningStartupCheckStrategyTest.java b/core/src/test/java/org/testcontainers/containers/startupcheck/IsRunningStartupCheckStrategyTest.java new file mode 100644 index 00000000000..fc7a60c2115 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/startupcheck/IsRunningStartupCheckStrategyTest.java @@ -0,0 +1,37 @@ +package org.testcontainers.containers.startupcheck; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.testcontainers.TestImages; +import org.testcontainers.containers.GenericContainer; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class IsRunningStartupCheckStrategyTest { + + @Test + void testCommandQuickExitSuccess() { + try (GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE).withCommand("/bin/true")) { + container.start(); // should start with no Exception + } + } + + @Test + @Disabled("This test can fail to throw an AssertionError if the container doesn't fail quickly enough") + void testCommandQuickExitFailure() { + try (GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE).withCommand("/bin/false")) { + assertThatThrownBy(container::start) + .hasStackTraceContaining("Container startup failed") + .hasStackTraceContaining("Container did not start correctly"); + } + } + + @Test + void testCommandStaysRunning() { + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE).withCommand("/bin/sleep", "60") + ) { + container.start(); // should start with no Exception + } + } +} diff --git a/core/src/test/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheckTest.java b/core/src/test/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheckTest.java new file mode 100644 index 00000000000..c494100544f --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheckTest.java @@ -0,0 +1,79 @@ +package org.testcontainers.containers.wait.internal; + +import com.google.common.collect.ImmutableSet; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; + +import java.net.ServerSocket; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ExternalPortListeningCheckTest { + + private ServerSocket listeningSocket1; + + private ServerSocket listeningSocket2; + + private ServerSocket nonListeningSocket; + + private WaitStrategyTarget mockContainer; + + @BeforeEach + public void setUp() throws Exception { + listeningSocket1 = new ServerSocket(0); + listeningSocket2 = new ServerSocket(0); + + nonListeningSocket = new ServerSocket(0); + nonListeningSocket.close(); + + mockContainer = mock(WaitStrategyTarget.class); + when(mockContainer.getHost()).thenReturn("127.0.0.1"); + } + + @Test + void singleListening() { + final ExternalPortListeningCheck check = new ExternalPortListeningCheck( + mockContainer, + ImmutableSet.of(listeningSocket1.getLocalPort()) + ); + + final Boolean result = check.call(); + + assertThat(result).as("ExternalPortListeningCheck identifies a single listening port").isTrue(); + } + + @Test + void multipleListening() { + final ExternalPortListeningCheck check = new ExternalPortListeningCheck( + mockContainer, + ImmutableSet.of(listeningSocket1.getLocalPort(), listeningSocket2.getLocalPort()) + ); + + final Boolean result = check.call(); + + assertThat(result).as("ExternalPortListeningCheck identifies multiple listening port").isTrue(); + } + + @Test + void oneNotListening() { + final ExternalPortListeningCheck check = new ExternalPortListeningCheck( + mockContainer, + ImmutableSet.of(listeningSocket1.getLocalPort(), nonListeningSocket.getLocalPort()) + ); + + assertThat(catchThrowable(check::call)) + .as("ExternalPortListeningCheck detects a non-listening port among many") + .isInstanceOf(IllegalStateException.class); + } + + @AfterEach + public void tearDown() throws Exception { + listeningSocket1.close(); + listeningSocket2.close(); + } +} diff --git a/core/src/test/java/org/testcontainers/containers/wait/internal/InternalCommandPortListeningCheckTest.java b/core/src/test/java/org/testcontainers/containers/wait/internal/InternalCommandPortListeningCheckTest.java new file mode 100644 index 00000000000..936be73fd3e --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/wait/internal/InternalCommandPortListeningCheckTest.java @@ -0,0 +1,74 @@ +package org.testcontainers.containers.wait.internal; + +import com.google.common.collect.ImmutableSet; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.MethodSource; +import org.rnorth.ducttape.TimeoutException; +import org.rnorth.ducttape.unreliables.Unreliables; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.images.builder.ImageFromDockerfile; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.fail; + +@ParameterizedClass(name = "{index} - {0}") +@MethodSource("data") +class InternalCommandPortListeningCheckTest { + + public static List data() { + return Arrays.asList( + "internal-port-check-dockerfile/Dockerfile-tcp", + "internal-port-check-dockerfile/Dockerfile-nc", + "internal-port-check-dockerfile/Dockerfile-bash" + ); + } + + public GenericContainer container; + + public InternalCommandPortListeningCheckTest(String dockerfile) { + container = + new GenericContainer<>( + new ImageFromDockerfile() + .withFileFromClasspath("Dockerfile", dockerfile) + .withFileFromClasspath("nginx.conf", "internal-port-check-dockerfile/nginx.conf") + ); + container.start(); + } + + @Test + void singleListening() { + final InternalCommandPortListeningCheck check = new InternalCommandPortListeningCheck( + container, + ImmutableSet.of(8080) + ); + + Unreliables.retryUntilTrue(5, TimeUnit.SECONDS, check); + } + + @Test + void nonListening() { + final InternalCommandPortListeningCheck check = new InternalCommandPortListeningCheck( + container, + ImmutableSet.of(8080, 1234) + ); + + try { + Unreliables.retryUntilTrue(5, TimeUnit.SECONDS, check); + fail("expected to fail"); + } catch (TimeoutException e) {} + } + + @Test + void lowAndHighPortListening() { + final InternalCommandPortListeningCheck check = new InternalCommandPortListeningCheck( + container, + ImmutableSet.of(100, 8080) + ); + + Unreliables.retryUntilTrue(5, TimeUnit.SECONDS, check); + } +} diff --git a/core/src/test/java/org/testcontainers/containers/wait/strategy/DockerHealthcheckWaitStrategyTest.java b/core/src/test/java/org/testcontainers/containers/wait/strategy/DockerHealthcheckWaitStrategyTest.java new file mode 100644 index 00000000000..653bbca2e33 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/wait/strategy/DockerHealthcheckWaitStrategyTest.java @@ -0,0 +1,45 @@ +package org.testcontainers.containers.wait.strategy; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.images.builder.ImageFromDockerfile; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +class DockerHealthcheckWaitStrategyTest { + + private GenericContainer container; + + @BeforeEach + public void setUp() { + // Using a Dockerfile here, since Dockerfile builder DSL doesn't support HEALTHCHECK + container = + new GenericContainer( + new ImageFromDockerfile() + .withFileFromClasspath( + "write_file_and_loop.sh", + "health-wait-strategy-dockerfile/write_file_and_loop.sh" + ) + .withFileFromClasspath("Dockerfile", "health-wait-strategy-dockerfile/Dockerfile") + ) + .waitingFor(Wait.forHealthcheck().withStartupTimeout(Duration.ofSeconds(3))); + } + + @Test + void startsOnceHealthy() { + container.start(); + } + + @Test + void containerStartFailsIfContainerIsUnhealthy() { + container.withCommand("tail", "-f", "/dev/null"); + assertThat(catchThrowable(container::start)) + .as("Container launch fails when unhealthy") + .isInstanceOf(ContainerLaunchException.class); + } +} diff --git a/core/src/test/java/org/testcontainers/containers/wait/strategy/WaitAllStrategyTest.java b/core/src/test/java/org/testcontainers/containers/wait/strategy/WaitAllStrategyTest.java new file mode 100644 index 00000000000..aeebe425776 --- /dev/null +++ b/core/src/test/java/org/testcontainers/containers/wait/strategy/WaitAllStrategyTest.java @@ -0,0 +1,177 @@ +package org.testcontainers.containers.wait.strategy; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.rnorth.ducttape.TimeoutException; +import org.testcontainers.containers.GenericContainer; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class WaitAllStrategyTest { + + @Mock + private GenericContainer container; + + @Mock + private WaitStrategy strategy1; + + @Mock + private WaitStrategy strategy2; + + @Mock + private WaitStrategy strategy3; + + @BeforeEach + public void setUp() { + MockitoAnnotations.initMocks(this); + } + + /* + * Dummy-based tests, to check that timeout values are propagated correctly, without involving actual timing-sensitive code + */ + @Test + void parentTimeoutApplies() { + DummyStrategy child1 = new DummyStrategy(Duration.ofMillis(10)); + child1.withStartupTimeout(Duration.ofMillis(20)); + + assertThat(child1.startupTimeout.toMillis()).as("withStartupTimeout directly sets the timeout").isEqualTo(20L); + + new WaitAllStrategy().withStrategy(child1).withStartupTimeout(Duration.ofMillis(30)); + + assertThat(child1.startupTimeout.toMillis()).as("WaitAllStrategy overrides a child's timeout").isEqualTo(30L); + } + + @Test + void parentTimeoutAppliesToMultipleChildren() { + Duration defaultInnerWait = Duration.ofMillis(2); + Duration outerWait = Duration.ofMillis(6); + + DummyStrategy child1 = new DummyStrategy(defaultInnerWait); + DummyStrategy child2 = new DummyStrategy(defaultInnerWait); + + new WaitAllStrategy().withStrategy(child1).withStrategy(child2).withStartupTimeout(outerWait); + + assertThat(child1.startupTimeout.toMillis()) + .as("WaitAllStrategy overrides a child's timeout (1st)") + .isEqualTo(6L); + assertThat(child2.startupTimeout.toMillis()) + .as("WaitAllStrategy overrides a child's timeout (2nd)") + .isEqualTo(6L); + } + + @Test + void parentTimeoutAppliesToAdditionalChildren() { + Duration defaultInnerWait = Duration.ofMillis(2); + Duration outerWait = Duration.ofMillis(20); + + DummyStrategy child1 = new DummyStrategy(defaultInnerWait); + DummyStrategy child2 = new DummyStrategy(defaultInnerWait); + + new WaitAllStrategy().withStrategy(child1).withStartupTimeout(outerWait).withStrategy(child2); + + assertThat(child1.startupTimeout.toMillis()) + .as("WaitAllStrategy overrides a child's timeout (1st)") + .isEqualTo(20L); + assertThat(child2.startupTimeout.toMillis()) + .as("WaitAllStrategy overrides a child's timeout (2nd, additional)") + .isEqualTo(20L); + } + + /* + * Mock-based tests to check overall behaviour, without involving timing-sensitive code + */ + @Test + void childExecutionTest() { + final WaitStrategy underTest = new WaitAllStrategy().withStrategy(strategy1).withStrategy(strategy2); + + doNothing().when(strategy1).waitUntilReady(eq(container)); + doNothing().when(strategy2).waitUntilReady(eq(container)); + + underTest.waitUntilReady(container); + + InOrder inOrder = inOrder(strategy1, strategy2); + inOrder.verify(strategy1).waitUntilReady(any()); + inOrder.verify(strategy2).waitUntilReady(any()); + } + + @Test + void withoutOuterTimeoutShouldRelyOnInnerStrategies() { + final WaitStrategy underTest = new WaitAllStrategy(WaitAllStrategy.Mode.WITH_INDIVIDUAL_TIMEOUTS_ONLY) + .withStrategy(strategy1) + .withStrategy(strategy2) + .withStrategy(strategy3); + + doNothing().when(strategy1).waitUntilReady(eq(container)); + doThrow(TimeoutException.class).when(strategy2).waitUntilReady(eq(container)); + + assertThat( + catchThrowable(() -> { + underTest.waitUntilReady(container); + }) + ) + .as("The outer strategy timeout applies") + .isInstanceOf(TimeoutException.class); + + InOrder inOrder = inOrder(strategy1, strategy2, strategy3); + inOrder.verify(strategy1).waitUntilReady(any()); + inOrder.verify(strategy2).waitUntilReady(any()); + inOrder.verify(strategy3, never()).waitUntilReady(any()); + } + + @Test + void timeoutChangeShouldNotBePossibleWithIndividualTimeoutMode() { + final WaitStrategy underTest = new WaitAllStrategy(WaitAllStrategy.Mode.WITH_INDIVIDUAL_TIMEOUTS_ONLY); + + assertThat( + catchThrowable(() -> { + underTest.withStartupTimeout(Duration.ofSeconds(42)); + }) + ) + .as("Cannot change timeout for individual timeouts") + .isInstanceOf(IllegalStateException.class); + } + + @Test + void shouldNotMessWithIndividualTimeouts() { + new WaitAllStrategy(WaitAllStrategy.Mode.WITH_INDIVIDUAL_TIMEOUTS_ONLY) + .withStrategy(strategy1) + .withStrategy(strategy2); + + verify(strategy1, never()).withStartupTimeout(any()); + verify(strategy1, never()).withStartupTimeout(any()); + } + + @Test + void shouldOverwriteIndividualTimeouts() { + Duration someSeconds = Duration.ofSeconds(23); + new WaitAllStrategy().withStartupTimeout(someSeconds).withStrategy(strategy1).withStrategy(strategy2); + + verify(strategy1).withStartupTimeout(someSeconds); + verify(strategy1).withStartupTimeout(someSeconds); + } + + static class DummyStrategy extends AbstractWaitStrategy { + + DummyStrategy(Duration defaultInnerWait) { + super.startupTimeout = defaultInnerWait; + } + + @Override + protected void waitUntilReady() { + // no-op + } + } +} diff --git a/core/src/test/java/org/testcontainers/custom/TestCreateContainerCmdModifier.java b/core/src/test/java/org/testcontainers/custom/TestCreateContainerCmdModifier.java new file mode 100644 index 00000000000..a09a92865ff --- /dev/null +++ b/core/src/test/java/org/testcontainers/custom/TestCreateContainerCmdModifier.java @@ -0,0 +1,19 @@ +package org.testcontainers.custom; + +import com.github.dockerjava.api.command.CreateContainerCmd; +import org.testcontainers.core.CreateContainerCmdModifier; + +import java.util.HashMap; +import java.util.Map; + +public class TestCreateContainerCmdModifier implements CreateContainerCmdModifier { + + @Override + public CreateContainerCmd modify(CreateContainerCmd createContainerCmd) { + Map labels = new HashMap<>(); + labels.put("project", "testcontainers-java"); + labels.put("scope", "global"); + createContainerCmd.getLabels().putAll(labels); + return createContainerCmd; + } +} diff --git a/core/src/test/java/org/testcontainers/dockerclient/AmbiguousImagePullTest.java b/core/src/test/java/org/testcontainers/dockerclient/AmbiguousImagePullTest.java new file mode 100644 index 00000000000..df351b9fd74 --- /dev/null +++ b/core/src/test/java/org/testcontainers/dockerclient/AmbiguousImagePullTest.java @@ -0,0 +1,26 @@ +package org.testcontainers.dockerclient; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.DockerRegistryContainer; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +class AmbiguousImagePullTest { + + @Test + @Timeout(30) + void testNotUsingParse() { + try (DockerRegistryContainer registryContainer = new DockerRegistryContainer()) { + registryContainer.start(); + DockerImageName imageName = registryContainer.createImage("latest"); + String imageNameWithoutTag = imageName.getRegistry() + "/" + imageName.getRepository(); + try ( + final GenericContainer container = new GenericContainer<>(imageNameWithoutTag).withExposedPorts(8080) + ) { + container.start(); + // do nothing other than start and stop + } + } + } +} diff --git a/core/src/test/java/org/testcontainers/dockerclient/DockerClientConfigUtilsTest.java b/core/src/test/java/org/testcontainers/dockerclient/DockerClientConfigUtilsTest.java index e4e29550b9c..f3bc15d2c22 100644 --- a/core/src/test/java/org/testcontainers/dockerclient/DockerClientConfigUtilsTest.java +++ b/core/src/test/java/org/testcontainers/dockerclient/DockerClientConfigUtilsTest.java @@ -1,53 +1,64 @@ package org.testcontainers.dockerclient; -import com.github.dockerjava.core.DefaultDockerClientConfig; -import com.github.dockerjava.core.DockerClientConfig; -import org.junit.Ignore; -import org.junit.Test; +import com.github.dockerjava.api.DockerClient; +import org.assertj.core.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.DockerClientFactory; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; +import java.net.URI; -public class DockerClientConfigUtilsTest { +import static org.assertj.core.api.Assertions.assertThat; + +class DockerClientConfigUtilsTest { + + DockerClient client = DockerClientFactory.lazyClient(); @Test - public void getDockerHostIpAddressShouldReturnLocalhostWhenUnixSocket() { - DockerClientConfig configuration = DefaultDockerClientConfig.createDefaultConfigBuilder() - .withDockerHost("unix:///var/run/docker.sock") - .withDockerTlsVerify(false) // TODO - check wrt. https://github.com/docker-java/docker-java/issues/588 - .build(); - String actual = DockerClientConfigUtils.getDockerHostIpAddress(configuration); - assertEquals("localhost", actual); + void getDockerHostIpAddressShouldReturnLocalhostWhenUnixSocket() { + Assumptions.assumeThat(DockerClientConfigUtils.IN_A_CONTAINER).as("in a container").isFalse(); + + String actual = DockerClientProviderStrategy.resolveDockerHostIpAddress( + client, + URI.create("unix:///var/run/docker.sock"), + true + ); + assertThat(actual).isEqualTo("localhost"); } - @Test @Ignore - public void getDockerHostIpAddressShouldReturnDockerHostIpWhenHttpUri() { - DockerClientConfig configuration = DefaultDockerClientConfig.createDefaultConfigBuilder().withDockerHost("http://12.23.34.45").build(); - String actual = DockerClientConfigUtils.getDockerHostIpAddress(configuration); - assertEquals("12.23.34.45", actual); + @Test + void getDockerHostIpAddressShouldReturnDockerHostIpWhenHttpsUri() { + String actual = DockerClientProviderStrategy.resolveDockerHostIpAddress( + client, + URI.create("http://12.23.34.45"), + true + ); + assertThat(actual).isEqualTo("12.23.34.45"); } - @Test @Ignore - public void getDockerHostIpAddressShouldReturnDockerHostIpWhenHttpsUri() { - DockerClientConfig configuration = DefaultDockerClientConfig.createDefaultConfigBuilder().withDockerHost("https://12.23.34.45").build(); - String actual = DockerClientConfigUtils.getDockerHostIpAddress(configuration); - assertEquals("12.23.34.45", actual); + @Test + void getDockerHostIpAddressShouldReturnDockerHostIpWhenTcpUri() { + String actual = DockerClientProviderStrategy.resolveDockerHostIpAddress( + client, + URI.create("tcp://12.23.34.45"), + true + ); + assertThat(actual).isEqualTo("12.23.34.45"); } - + @Test - public void getDockerHostIpAddressShouldReturnDockerHostIpWhenTcpUri() { - DockerClientConfig configuration = DefaultDockerClientConfig.createDefaultConfigBuilder() - .withDockerHost("tcp://12.23.34.45") - .withDockerTlsVerify(false) // TODO - check wrt. https://github.com/docker-java/docker-java/issues/588 - .build(); - String actual = DockerClientConfigUtils.getDockerHostIpAddress(configuration); - assertEquals("12.23.34.45", actual); + void getDockerHostIpAddressShouldReturnNullWhenUnsupportedUriScheme() { + String actual = DockerClientProviderStrategy.resolveDockerHostIpAddress( + client, + URI.create("gopher://12.23.34.45"), + true + ); + assertThat(actual).isNull(); } - - @Test @Ignore - public void getDockerHostIpAddressShouldReturnNullWhenUnsupportedUriScheme() { - DockerClientConfig configuration = DefaultDockerClientConfig.createDefaultConfigBuilder().withDockerHost("gopher://12.23.34.45").build(); - String actual = DockerClientConfigUtils.getDockerHostIpAddress(configuration); - assertNull(actual); + + @Test + @Timeout(5) + void getDefaultGateway() { + assertThat(DockerClientConfigUtils.getDefaultGateway()).isNotNull(); } } diff --git a/core/src/test/java/org/testcontainers/dockerclient/EnvironmentAndSystemPropertyClientProviderStrategyTest.java b/core/src/test/java/org/testcontainers/dockerclient/EnvironmentAndSystemPropertyClientProviderStrategyTest.java new file mode 100644 index 00000000000..be4dcd446a8 --- /dev/null +++ b/core/src/test/java/org/testcontainers/dockerclient/EnvironmentAndSystemPropertyClientProviderStrategyTest.java @@ -0,0 +1,157 @@ +package org.testcontainers.dockerclient; + +import com.github.dockerjava.core.DefaultDockerClientConfig; +import com.github.dockerjava.transport.SSLConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.testcontainers.utility.MockTestcontainersConfigurationExtension; +import org.testcontainers.utility.TestcontainersConfiguration; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; + +/** + * Test that we can use Testcontainers configuration file to override settings. We assume that docker-java has test + * coverage for detection of environment variables (e.g. DOCKER_HOST) and its own properties config file. + */ +@ExtendWith(MockTestcontainersConfigurationExtension.class) +class EnvironmentAndSystemPropertyClientProviderStrategyTest { + + private URI defaultDockerHost; + + private com.github.dockerjava.core.SSLConfig defaultSSLConfig; + + @BeforeEach + public void checkEnvironmentClear() { + // If docker-java picks up non-default settings from the environment, our test needs to know to expect those + DefaultDockerClientConfig defaultConfig = DefaultDockerClientConfig.createDefaultConfigBuilder().build(); + defaultDockerHost = defaultConfig.getDockerHost(); + defaultSSLConfig = defaultConfig.getSSLConfig(); + } + + @Test + void testWhenConfigAbsent() { + Mockito + .doReturn("auto") + .when(TestcontainersConfiguration.getInstance()) + .getEnvVarOrProperty(eq("dockerconfig.source"), anyString()); + Mockito + .doReturn(null) + .when(TestcontainersConfiguration.getInstance()) + .getEnvVarOrUserProperty(eq("docker.host"), isNull()); + Mockito + .doReturn(null) + .when(TestcontainersConfiguration.getInstance()) + .getEnvVarOrUserProperty(eq("docker.tls.verify"), isNull()); + Mockito + .doReturn(null) + .when(TestcontainersConfiguration.getInstance()) + .getEnvVarOrUserProperty(eq("docker.cert.path"), isNull()); + + EnvironmentAndSystemPropertyClientProviderStrategy strategy = new EnvironmentAndSystemPropertyClientProviderStrategy(); + + TransportConfig transportConfig = strategy.getTransportConfig(); + assertThat(transportConfig.getDockerHost()).isEqualTo(defaultDockerHost); + assertThat(transportConfig.getSslConfig()).isEqualTo(defaultSSLConfig); + } + + @Test + void testWhenDockerHostPresent() { + Mockito + .doReturn("auto") + .when(TestcontainersConfiguration.getInstance()) + .getEnvVarOrProperty(eq("dockerconfig.source"), anyString()); + Mockito + .doReturn("tcp://1.2.3.4:2375") + .when(TestcontainersConfiguration.getInstance()) + .getEnvVarOrUserProperty(eq("docker.host"), isNull()); + Mockito + .doReturn(null) + .when(TestcontainersConfiguration.getInstance()) + .getEnvVarOrUserProperty(eq("docker.tls.verify"), isNull()); + Mockito + .doReturn(null) + .when(TestcontainersConfiguration.getInstance()) + .getEnvVarOrUserProperty(eq("docker.cert.path"), isNull()); + + EnvironmentAndSystemPropertyClientProviderStrategy strategy = new EnvironmentAndSystemPropertyClientProviderStrategy(); + + TransportConfig transportConfig = strategy.getTransportConfig(); + assertThat(transportConfig.getDockerHost().toString()).isEqualTo("tcp://1.2.3.4:2375"); + assertThat(transportConfig.getSslConfig()).isEqualTo(defaultSSLConfig); + } + + @Test + void testWhenDockerHostAndSSLConfigPresent() throws IOException { + Path tempDir = Files.createTempDirectory("testcontainers-test"); + String tempDirPath = tempDir.toAbsolutePath().toString(); + + Mockito + .doReturn("auto") + .when(TestcontainersConfiguration.getInstance()) + .getEnvVarOrProperty(eq("dockerconfig.source"), anyString()); + Mockito + .doReturn("tcp://1.2.3.4:2375") + .when(TestcontainersConfiguration.getInstance()) + .getEnvVarOrUserProperty(eq("docker.host"), isNull()); + Mockito + .doReturn("1") + .when(TestcontainersConfiguration.getInstance()) + .getEnvVarOrUserProperty(eq("docker.tls.verify"), isNull()); + Mockito + .doReturn(tempDirPath) + .when(TestcontainersConfiguration.getInstance()) + .getEnvVarOrUserProperty(eq("docker.cert.path"), isNull()); + + EnvironmentAndSystemPropertyClientProviderStrategy strategy = new EnvironmentAndSystemPropertyClientProviderStrategy(); + + TransportConfig transportConfig = strategy.getTransportConfig(); + assertThat(transportConfig.getDockerHost().toString()).isEqualTo("tcp://1.2.3.4:2375"); + + SSLConfig sslConfig = transportConfig.getSslConfig(); + assertThat(sslConfig).extracting("dockerCertPath").isEqualTo(tempDirPath); + } + + @Test + void applicableWhenIgnoringUserPropertiesAndConfigured() { + Mockito + .doReturn("autoIgnoringUserProperties") + .when(TestcontainersConfiguration.getInstance()) + .getEnvVarOrProperty(eq("dockerconfig.source"), anyString()); + + Properties oldProperties = System.getProperties(); + try { + System.setProperty("DOCKER_HOST", "tcp://1.2.3.4:2375"); + EnvironmentAndSystemPropertyClientProviderStrategy strategy = new EnvironmentAndSystemPropertyClientProviderStrategy(); + + assertThat(strategy.isApplicable()).isTrue(); + } finally { + System.setProperties(oldProperties); + } + } + + @Test + void notApplicableWhenIgnoringUserPropertiesAndNotConfigured() { + assumeThat(System.getenv("DOCKER_HOST")).isNull(); + + Mockito + .doReturn("autoIgnoringUserProperties") + .when(TestcontainersConfiguration.getInstance()) + .getEnvVarOrProperty(eq("dockerconfig.source"), anyString()); + + EnvironmentAndSystemPropertyClientProviderStrategy strategy = new EnvironmentAndSystemPropertyClientProviderStrategy(); + + assertThat(strategy.isApplicable()).isFalse(); + } +} diff --git a/core/src/test/java/org/testcontainers/dockerclient/EventStreamTest.java b/core/src/test/java/org/testcontainers/dockerclient/EventStreamTest.java new file mode 100644 index 00000000000..2976c56c7a9 --- /dev/null +++ b/core/src/test/java/org/testcontainers/dockerclient/EventStreamTest.java @@ -0,0 +1,69 @@ +package org.testcontainers.dockerclient; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.model.Event; +import com.github.dockerjava.core.command.EventsResultCallback; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.TestImages; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; + +import java.io.IOException; +import java.time.Instant; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Test that event streaming from the {@link DockerClient} works correctly + */ +@Timeout(10) +class EventStreamTest { + + /** + * Test that docker events can be streamed from the client. + */ + @Test + void test() throws IOException, InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withCommand("true") + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + ) { + container.start(); + String createdAt = container.getContainerInfo().getCreated(); + + // Request all events between startTime and endTime for the container + try ( + EventsResultCallback response = DockerClientFactory + .instance() + .client() + .eventsCmd() + .withContainerFilter(container.getContainerId()) + .withEventFilter("create") + .withSince(Instant.parse(createdAt).getEpochSecond() + "") + .exec( + new EventsResultCallback() { + @Override + public void onNext(@NotNull Event event) { + // Check that a create event for the container is received + if ( + event.getId().equals(container.getContainerId()) && + event.getStatus().equals("create") + ) { + latch.countDown(); + } + } + } + ) + ) { + response.awaitStarted(5, TimeUnit.SECONDS); + latch.await(5, TimeUnit.SECONDS); + } + } + } +} diff --git a/core/src/test/java/org/testcontainers/dockerclient/ImagePullTest.java b/core/src/test/java/org/testcontainers/dockerclient/ImagePullTest.java new file mode 100644 index 00000000000..d5329430e71 --- /dev/null +++ b/core/src/test/java/org/testcontainers/dockerclient/ImagePullTest.java @@ -0,0 +1,36 @@ +package org.testcontainers.dockerclient; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; +import org.testcontainers.utility.DockerImageName; + +class ImagePullTest { + + public static String[] parameters() { + return new String[] { + "alpine:latest", + "alpine:3.17", + "alpine", // omitting the tag should work and default to latest + "alpine@sha256:1775bebec23e1f3ce486989bfc9ff3c4e951690df84aa9f926497d82f2ffca9d", + "docker.io/testcontainers/ryuk:latest", + "docker.io/testcontainers/ryuk:0.7.0", + "docker.io/testcontainers/ryuk@sha256:bcbee39cd601396958ba1bd06ea14ad64ce0ea709de29a427d741d1f5262080a", + // "ibmcom/db2express-c", // Big image for testing with slow networks + }; + } + + @ParameterizedTest(name = "{0}") + @MethodSource("parameters") + void test(String image) { + try ( + final GenericContainer container = new GenericContainer<>(DockerImageName.parse(image)) + .withCommand("/bin/sh", "-c", "sleep 0") + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + ) { + container.start(); + // do nothing other than start and stop + } + } +} diff --git a/core/src/test/java/org/testcontainers/dockerclient/TestcontainersHostPropertyClientProviderStrategyTest.java b/core/src/test/java/org/testcontainers/dockerclient/TestcontainersHostPropertyClientProviderStrategyTest.java new file mode 100644 index 00000000000..2c3d3ac40de --- /dev/null +++ b/core/src/test/java/org/testcontainers/dockerclient/TestcontainersHostPropertyClientProviderStrategyTest.java @@ -0,0 +1,38 @@ +package org.testcontainers.dockerclient; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.testcontainers.utility.MockTestcontainersConfigurationExtension; +import org.testcontainers.utility.TestcontainersConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; + +@ExtendWith(MockTestcontainersConfigurationExtension.class) +class TestcontainersHostPropertyClientProviderStrategyTest { + + @Test + void tcHostPropertyIsProvided() { + Mockito + .doReturn("tcp://127.0.0.1:9000") + .when(TestcontainersConfiguration.getInstance()) + .getUserProperty(eq("tc.host"), isNull()); + + TestcontainersHostPropertyClientProviderStrategy strategy = new TestcontainersHostPropertyClientProviderStrategy(); + + assertThat(strategy.isApplicable()).isTrue(); + TransportConfig transportConfig = strategy.getTransportConfig(); + assertThat(transportConfig.getDockerHost().toString()).isEqualTo("tcp://127.0.0.1:9000"); + } + + @Test + void tcHostPropertyIsNotProvided() { + Mockito.doReturn(null).when(TestcontainersConfiguration.getInstance()).getUserProperty(eq("tc.host"), isNull()); + + TestcontainersHostPropertyClientProviderStrategy strategy = new TestcontainersHostPropertyClientProviderStrategy(); + + assertThat(strategy.isApplicable()).isFalse(); + } +} diff --git a/core/src/test/java/org/testcontainers/images/AgeBasedPullPolicyTest.java b/core/src/test/java/org/testcontainers/images/AgeBasedPullPolicyTest.java new file mode 100644 index 00000000000..254633ea6e7 --- /dev/null +++ b/core/src/test/java/org/testcontainers/images/AgeBasedPullPolicyTest.java @@ -0,0 +1,27 @@ +package org.testcontainers.images; + +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class AgeBasedPullPolicyTest { + + final DockerImageName imageName = DockerImageName.parse(UUID.randomUUID().toString()); + + @Test + void shouldPull() { + ImageData imageData = ImageData.builder().createdAt(Instant.now().minus(2, ChronoUnit.HOURS)).build(); + + AgeBasedPullPolicy oneHour = new AgeBasedPullPolicy(Duration.of(1L, ChronoUnit.HOURS)); + assertThat(oneHour.shouldPullCached(imageName, imageData)).isTrue(); + + AgeBasedPullPolicy fiveHours = new AgeBasedPullPolicy(Duration.of(5L, ChronoUnit.HOURS)); + assertThat(fiveHours.shouldPullCached(imageName, imageData)).isFalse(); + } +} diff --git a/core/src/test/java/org/testcontainers/images/ImageDataTest.java b/core/src/test/java/org/testcontainers/images/ImageDataTest.java new file mode 100644 index 00000000000..b9fe0ae3c42 --- /dev/null +++ b/core/src/test/java/org/testcontainers/images/ImageDataTest.java @@ -0,0 +1,25 @@ +package org.testcontainers.images; + +import com.github.dockerjava.api.command.InspectImageResponse; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class ImageDataTest { + + @Test + void shouldReadTimestampWithoutOffsetFromInspectImageResponse() { + final String timestamp = "2020-07-27T18:23:31.365190246Z"; + final ImageData imageData = ImageData.from(new InspectImageResponse().withCreated(timestamp)); + assertThat(imageData.getCreatedAt()).isEqualTo(Instant.parse(timestamp)); + } + + @Test + void shouldReadTimestampWithOffsetFromInspectImageResponse() { + final String timestamp = "2020-07-27T18:23:31.365190246+02:00"; + final ImageData imageData = ImageData.from(new InspectImageResponse().withCreated(timestamp)); + assertThat(imageData.getCreatedAt()).isEqualTo(Instant.parse("2020-07-27T16:23:31.365190246Z")); + } +} diff --git a/core/src/test/java/org/testcontainers/images/ImagePullPolicyTest.java b/core/src/test/java/org/testcontainers/images/ImagePullPolicyTest.java new file mode 100644 index 00000000000..6c763de2962 --- /dev/null +++ b/core/src/test/java/org/testcontainers/images/ImagePullPolicyTest.java @@ -0,0 +1,136 @@ +package org.testcontainers.images; + +import com.github.dockerjava.api.exception.NotFoundException; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.DockerRegistryContainer; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; +import org.testcontainers.utility.DockerImageName; + +import static org.assertj.core.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; + +class ImagePullPolicyTest { + + @AutoClose + public static DockerRegistryContainer registry = new DockerRegistryContainer(); + + private final DockerImageName imageName = registry.createImage(); + + static { + registry.start(); + } + + @Test + void pullsByDefault() { + try (GenericContainer container = new GenericContainer<>(imageName).withExposedPorts(8080)) { + container.start(); + } + } + + @Test + void shouldAlwaysPull() { + try (GenericContainer container = new GenericContainer<>(imageName).withExposedPorts(8080)) { + container.start(); + } + + removeImage(); + + try (GenericContainer container = new GenericContainer<>(imageName).withExposedPorts(8080)) { + expectToFailWithNotFoundException(container); + } + + try ( + // built_in_image_pull_policy { + GenericContainer container = new GenericContainer<>(imageName) + .withImagePullPolicy(PullPolicy.alwaysPull()) + // } + ) { + container.withExposedPorts(8080); + container.start(); + } + } + + @Test + void shouldSupportCustomPolicies() { + try ( + // custom_image_pull_policy { + GenericContainer container = new GenericContainer<>(imageName) + .withImagePullPolicy( + new AbstractImagePullPolicy() { + @Override + protected boolean shouldPullCached(DockerImageName imageName, ImageData localImageData) { + return System.getenv("ALWAYS_PULL_IMAGE") != null; + } + } + ) + // } + ) { + container.withExposedPorts(8080); + container.start(); + } + } + + @Test + void shouldCheckPolicy() { + ImagePullPolicy policy = Mockito.spy( + new AbstractImagePullPolicy() { + @Override + protected boolean shouldPullCached(DockerImageName imageName, ImageData localImageData) { + return false; + } + } + ); + try ( + GenericContainer container = new GenericContainer<>(imageName) + .withImagePullPolicy(policy) + .withExposedPorts(8080) + ) { + container.start(); + + Mockito.verify(policy).shouldPull(any()); + } + } + + @Test + void shouldNotForcePulling() { + try ( + GenericContainer container = new GenericContainer<>(imageName) + .withImagePullPolicy(__ -> false) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + ) { + expectToFailWithNotFoundException(container); + } + } + + private void expectToFailWithNotFoundException(GenericContainer container) { + try { + container.start(); + fail("Should fail"); + } catch (ContainerLaunchException e) { + Throwable throwable = e; + while (throwable.getCause() != null) { + throwable = throwable.getCause(); + if (throwable.getCause() instanceof NotFoundException) { + return; + } + } + fail("Caused by NotFoundException"); + } + } + + private void removeImage() { + try { + DockerClientFactory + .instance() + .client() + .removeImageCmd(imageName.asCanonicalNameString()) + .withForce(true) + .exec(); + } catch (NotFoundException ignored) {} + } +} diff --git a/core/src/test/java/org/testcontainers/images/LocalImagesCacheAccessor.java b/core/src/test/java/org/testcontainers/images/LocalImagesCacheAccessor.java new file mode 100644 index 00000000000..4e7a28c535a --- /dev/null +++ b/core/src/test/java/org/testcontainers/images/LocalImagesCacheAccessor.java @@ -0,0 +1,9 @@ +package org.testcontainers.images; + +public final class LocalImagesCacheAccessor { + + public static synchronized void clearCache() { + LocalImagesCache.INSTANCE.cache.clear(); + LocalImagesCache.INSTANCE.initialized.set(false); + } +} diff --git a/core/src/test/java/org/testcontainers/images/OverrideImagePullPolicyTest.java b/core/src/test/java/org/testcontainers/images/OverrideImagePullPolicyTest.java new file mode 100644 index 00000000000..a18d0130383 --- /dev/null +++ b/core/src/test/java/org/testcontainers/images/OverrideImagePullPolicyTest.java @@ -0,0 +1,68 @@ +package org.testcontainers.images; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.testcontainers.DockerRegistryContainer; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.FakeImagePullPolicy; +import org.testcontainers.utility.MockTestcontainersConfigurationExtension; +import org.testcontainers.utility.TestcontainersConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockTestcontainersConfigurationExtension.class) +class OverrideImagePullPolicyTest { + + private ImagePullPolicy originalInstance; + + private ImagePullPolicy originalDefaultImplementation; + + @BeforeEach + public void setUp() { + this.originalInstance = PullPolicy.instance; + this.originalDefaultImplementation = PullPolicy.defaultImplementation; + PullPolicy.instance = null; + PullPolicy.defaultImplementation = Mockito.mock(ImagePullPolicy.class); + } + + @AfterEach + public void tearDown() { + PullPolicy.instance = originalInstance; + PullPolicy.defaultImplementation = originalDefaultImplementation; + } + + @Test + void simpleConfigurationTest() { + Mockito + .doReturn(FakeImagePullPolicy.class.getCanonicalName()) + .when(TestcontainersConfiguration.getInstance()) + .getImagePullPolicy(); + + try (DockerRegistryContainer registry = new DockerRegistryContainer()) { + registry.start(); + GenericContainer container = new GenericContainer<>(registry.createImage()).withExposedPorts(8080); + container.start(); + assertThat(container.getImage().imagePullPolicy).isInstanceOf(FakeImagePullPolicy.class); + container.stop(); + } + } + + @Test + void alwaysPullConfigurationTest() { + Mockito + .doReturn(AlwaysPullPolicy.class.getCanonicalName()) + .when(TestcontainersConfiguration.getInstance()) + .getImagePullPolicy(); + + try (DockerRegistryContainer registry = new DockerRegistryContainer()) { + registry.start(); + GenericContainer container = new GenericContainer<>(registry.createImage()).withExposedPorts(8080); + container.start(); + assertThat(container.getImage().imagePullPolicy).isInstanceOf(AlwaysPullPolicy.class); + container.stop(); + } + } +} diff --git a/core/src/test/java/org/testcontainers/images/ParsedDockerfileTest.java b/core/src/test/java/org/testcontainers/images/ParsedDockerfileTest.java new file mode 100644 index 00000000000..e881f0167e2 --- /dev/null +++ b/core/src/test/java/org/testcontainers/images/ParsedDockerfileTest.java @@ -0,0 +1,110 @@ +package org.testcontainers.images; + +import com.google.common.collect.Sets; +import org.junit.jupiter.api.Test; + +import java.nio.file.Paths; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class ParsedDockerfileTest { + + @Test + void doesSimpleParsing() { + final ParsedDockerfile parsedDockerfile = new ParsedDockerfile( + Arrays.asList("FROM someimage", "RUN something") + ); + assertThat(parsedDockerfile.getDependencyImageNames()) + .as("extracts a single image name") + .isEqualTo(Sets.newHashSet("someimage")); + } + + @Test + void isCaseInsensitive() { + final ParsedDockerfile parsedDockerfile = new ParsedDockerfile( + Arrays.asList("from someimage", "RUN something") + ); + assertThat(parsedDockerfile.getDependencyImageNames()) + .as("extracts a single image name") + .isEqualTo(Sets.newHashSet("someimage")); + } + + @Test + void handlesTags() { + final ParsedDockerfile parsedDockerfile = new ParsedDockerfile( + Arrays.asList("FROM someimage:tag", "RUN something") + ); + assertThat(parsedDockerfile.getDependencyImageNames()) + .as("retains tags in image names") + .isEqualTo(Sets.newHashSet("someimage:tag")); + } + + @Test + void handlesDigests() { + final ParsedDockerfile parsedDockerfile = new ParsedDockerfile( + Arrays.asList("FROM someimage@sha256:abc123", "RUN something") + ); + assertThat(parsedDockerfile.getDependencyImageNames()) + .as("retains digests in image names") + .isEqualTo(Sets.newHashSet("someimage@sha256:abc123")); + } + + @Test + void ignoringCommentedFromLines() { + final ParsedDockerfile parsedDockerfile = new ParsedDockerfile( + Arrays.asList("FROM someimage", "#FROM somethingelse") + ); + assertThat(parsedDockerfile.getDependencyImageNames()) + .as("ignores commented from lines") + .isEqualTo(Sets.newHashSet("someimage")); + } + + @Test + void ignoringBuildStageNames() { + final ParsedDockerfile parsedDockerfile = new ParsedDockerfile( + Arrays.asList("FROM someimage --as=base", "RUN something", "FROM nextimage", "RUN something") + ); + assertThat(parsedDockerfile.getDependencyImageNames()) + .as("ignores build stage names and allows multiple images to be extracted") + .isEqualTo(Sets.newHashSet("someimage", "nextimage")); + } + + @Test + void ignoringPlatformArgs() { + final ParsedDockerfile parsedDockerfile = new ParsedDockerfile( + Arrays.asList("FROM --platform=linux/amd64 someimage", "RUN something") + ); + assertThat(parsedDockerfile.getDependencyImageNames()) + .as("ignores platform args") + .isEqualTo(Sets.newHashSet("someimage")); + } + + @Test + void ignoringExtraPlatformArgs() { + final ParsedDockerfile parsedDockerfile = new ParsedDockerfile( + Arrays.asList("FROM --platform=linux/amd64 --somethingelse=value someimage", "RUN something") + ); + assertThat(parsedDockerfile.getDependencyImageNames()) + .as("ignores platform args") + .isEqualTo(Sets.newHashSet("someimage")); + } + + @Test + void handlesGracefullyIfNoFromLine() { + final ParsedDockerfile parsedDockerfile = new ParsedDockerfile( + Arrays.asList("RUN something", "# is this even a valid Dockerfile?") + ); + assertThat(parsedDockerfile.getDependencyImageNames()) + .as("handles invalid Dockerfiles gracefully") + .isEqualTo(Sets.newHashSet()); + } + + @Test + void handlesGracefullyIfDockerfileNotFound() { + final ParsedDockerfile parsedDockerfile = new ParsedDockerfile(Paths.get("nonexistent.Dockerfile")); + assertThat(parsedDockerfile.getDependencyImageNames()) + .as("handles missing Dockerfiles gracefully") + .isEqualTo(Sets.newHashSet()); + } +} diff --git a/core/src/test/java/org/testcontainers/images/RemoteDockerImageTest.java b/core/src/test/java/org/testcontainers/images/RemoteDockerImageTest.java new file mode 100644 index 00000000000..156df3ae7d3 --- /dev/null +++ b/core/src/test/java/org/testcontainers/images/RemoteDockerImageTest.java @@ -0,0 +1,79 @@ +package org.testcontainers.images; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.utility.Base58; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.LazyFuture; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; + +class RemoteDockerImageTest { + + @Test + void toStringContainsOnlyImageName() { + String imageName = Base58.randomString(8).toLowerCase(); + RemoteDockerImage remoteDockerImage = new RemoteDockerImage(DockerImageName.parse(imageName)); + assertThat(remoteDockerImage.toString()).contains("imageName=" + imageName); + } + + @Test + void toStringWithExceptionContainsOnlyImageNameFuture() { + CompletableFuture imageNameFuture = new CompletableFuture<>(); + imageNameFuture.completeExceptionally(new RuntimeException("arbitrary")); + + RemoteDockerImage remoteDockerImage = new RemoteDockerImage(imageNameFuture); + assertThat(remoteDockerImage.toString()).contains("imageName=java.lang.RuntimeException: arbitrary"); + } + + @Test + @Timeout(5) + void toStringDoesntResolveImageNameFuture() { + CompletableFuture imageNameFuture = new CompletableFuture<>(); + + // verify that we've set up the test properly + assertThat(imageNameFuture).isNotDone(); + + RemoteDockerImage remoteDockerImage = new RemoteDockerImage(imageNameFuture); + assertThat(remoteDockerImage.toString()).contains("imageName="); + + // Make sure the act of calling toString doesn't resolve the imageNameFuture + assertThat(imageNameFuture).isNotDone(); + + String imageName = Base58.randomString(8).toLowerCase(); + imageNameFuture.complete(imageName); + assertThat(remoteDockerImage.toString()).contains("imageName=" + imageName); + } + + @Test + @Timeout(5) + void toStringDoesntResolveLazyFuture() throws Exception { + String imageName = Base58.randomString(8).toLowerCase(); + AtomicBoolean resolved = new AtomicBoolean(false); + Future imageNameFuture = new LazyFuture() { + @Override + protected String resolve() { + resolved.set(true); + return imageName; + } + }; + + // verify that we've set up the test properly + assertThat(imageNameFuture).isNotDone(); + + RemoteDockerImage remoteDockerImage = new RemoteDockerImage(imageNameFuture); + assertThat(remoteDockerImage.toString()).contains("imageName="); + + // Make sure the act of calling toString doesn't resolve the imageNameFuture + assertThat(imageNameFuture).isNotDone(); + assertThat(resolved).isFalse(); + + // Trigger resolve + imageNameFuture.get(); + assertThat(remoteDockerImage.toString()).contains("imageName=" + imageName); + } +} diff --git a/core/src/test/java/org/testcontainers/images/builder/DockerfileBuildTest.java b/core/src/test/java/org/testcontainers/images/builder/DockerfileBuildTest.java new file mode 100644 index 00000000000..fae6fc3193d --- /dev/null +++ b/core/src/test/java/org/testcontainers/images/builder/DockerfileBuildTest.java @@ -0,0 +1,86 @@ +package org.testcontainers.images.builder; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class DockerfileBuildTest { + + static final Path RESOURCE_PATH = Paths.get("src/test/resources/dockerfile-build-test"); + + public static Stream parameters() { + Map buildArgs = new HashMap<>(4); + buildArgs.put("BUILD_IMAGE", "alpine:3.16"); + buildArgs.put("BASE_IMAGE", "alpine"); + buildArgs.put("BASE_IMAGE_TAG", "3.12"); + buildArgs.put("UNUSED", "ignored"); + + //noinspection deprecation + return Stream.of( + // Dockerfile build without explicit per-file inclusion + Arguments.of( + "test1234", + // spotless:off + // docsShowRecursiveFileInclusion { + new ImageFromDockerfile() + .withFileFromPath(".", RESOURCE_PATH)), + // } + // spotless:on + // Dockerfile build using a non-standard Dockerfile + Arguments.of( + "test4567", + new ImageFromDockerfile().withFileFromPath(".", RESOURCE_PATH).withDockerfilePath("./Dockerfile-alt") + ), + // Dockerfile build using withBuildArg() + Arguments.of( + "test7890", + new ImageFromDockerfile() + .withFileFromPath(".", RESOURCE_PATH) + .withDockerfilePath("./Dockerfile-buildarg") + .withBuildArg("CUSTOM_ARG", "test7890") + ), + // Dockerfile build using withBuildArgs() with build args in FROM statement + Arguments.of( + "test1234", + new ImageFromDockerfile() + .withFileFromPath(".", RESOURCE_PATH) + .withDockerfile(RESOURCE_PATH.resolve("Dockerfile-from-buildarg")) + .withBuildArgs(buildArgs) + ), + // Dockerfile build using withDockerfile(File) + Arguments.of( + "test4567", + new ImageFromDockerfile() + .withFileFromPath(".", RESOURCE_PATH) + .withDockerfile(RESOURCE_PATH.resolve("Dockerfile-alt")) + ) + ); + } + + @ParameterizedTest + @MethodSource("parameters") + void performTest(String expectedFileContent, ImageFromDockerfile image) { + try ( + final GenericContainer container = new GenericContainer<>(image) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + .withCommand("cat", "/test.txt") + ) { + container.start(); + + final String logs = container.getLogs(); + assertThat(logs) + .as("expected file content indicates that dockerfile build steps have been run") + .contains(expectedFileContent); + } + } +} diff --git a/core/src/test/java/org/testcontainers/images/builder/DockerignoreTest.java b/core/src/test/java/org/testcontainers/images/builder/DockerignoreTest.java new file mode 100644 index 00000000000..6f7329f9dd2 --- /dev/null +++ b/core/src/test/java/org/testcontainers/images/builder/DockerignoreTest.java @@ -0,0 +1,56 @@ +package org.testcontainers.images.builder; + +import com.github.dockerjava.api.exception.DockerClientException; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; +import org.testcontainers.utility.DockerImageName; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +class DockerignoreTest { + + private static final Path INVALID_DOCKERIGNORE_PATH = Paths.get("src/test/resources/dockerfile-build-invalid"); + + @Test + void testInvalidDockerignore() throws Exception { + try { + new ImageFromDockerfile() + .withFileFromPath(".", INVALID_DOCKERIGNORE_PATH) + .withDockerfile(INVALID_DOCKERIGNORE_PATH.resolve("Dockerfile")) + .get(); + fail("Should not be able to build an image with an invalid .dockerignore file"); + } catch (DockerClientException e) { + if (!e.getMessage().contains("Invalid pattern")) { + throw e; + } + } + } + + @SuppressWarnings("resource") + @Test + void testValidDockerignore() throws Exception { + ImageFromDockerfile img = new ImageFromDockerfile() + .withFileFromPath(".", DockerfileBuildTest.RESOURCE_PATH) + .withDockerfile(DockerfileBuildTest.RESOURCE_PATH.resolve("Dockerfile-currentdir")); + try ( + final GenericContainer container = new GenericContainer(DockerImageName.parse(img.get())) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + .withCommand("ls", "/") + ) { + container.start(); + + final String logs = container.getLogs(); + assertThat(logs) + .as("Files in the container indicated the .dockerignore was not applied. Output was: " + logs) + .contains("should_not_be_ignored.txt"); + assertThat(logs) + .as("Files in the container indicated the .dockerignore was not applied. Output was: " + logs) + .doesNotContain("should_be_ignored.txt"); + } + } +} diff --git a/core/src/test/java/org/testcontainers/images/builder/ImageFromDockerfileTest.java b/core/src/test/java/org/testcontainers/images/builder/ImageFromDockerfileTest.java new file mode 100644 index 00000000000..0ce8ec041bf --- /dev/null +++ b/core/src/test/java/org/testcontainers/images/builder/ImageFromDockerfileTest.java @@ -0,0 +1,47 @@ +package org.testcontainers.images.builder; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.InspectImageResponse; +import org.junit.jupiter.api.Test; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.utility.Base58; + +import static org.assertj.core.api.Assertions.assertThat; + +class ImageFromDockerfileTest { + + @Test + void shouldAddDefaultLabels() { + ImageFromDockerfile image = new ImageFromDockerfile().withDockerfileFromBuilder(it -> it.from("scratch")); + + String imageId = image.resolve(); + + DockerClient dockerClient = DockerClientFactory.instance().client(); + + InspectImageResponse inspectImageResponse = dockerClient.inspectImageCmd(imageId).exec(); + + assertThat(inspectImageResponse.getConfig().getLabels()) + .containsAllEntriesOf(DockerClientFactory.DEFAULT_LABELS); + } + + @Test + void shouldNotAddSessionLabelIfDeleteOnExitIsFalse() { + ImageFromDockerfile image = new ImageFromDockerfile( + "localhost/testcontainers/" + Base58.randomString(16).toLowerCase(), + false + ) + .withDockerfileFromBuilder(it -> it.from("scratch")); + String imageId = image.resolve(); + + DockerClient dockerClient = DockerClientFactory.instance().client(); + + try { + InspectImageResponse inspectImageResponse = dockerClient.inspectImageCmd(imageId).exec(); + assertThat(inspectImageResponse.getConfig().getLabels()) + .doesNotContainKey(DockerClientFactory.TESTCONTAINERS_SESSION_ID_LABEL); + } finally { + // ensure the image is deleted, even if the test fails + dockerClient.removeImageCmd(imageId).exec(); + } + } +} diff --git a/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/AbstractStatementTest.java b/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/AbstractStatementTest.java index 35d325c5f40..28cb0407f25 100644 --- a/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/AbstractStatementTest.java +++ b/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/AbstractStatementTest.java @@ -1,28 +1,29 @@ package org.testcontainers.images.builder.dockerfile.statement; import org.apache.commons.io.IOUtils; -import org.apache.commons.lang.StringUtils; -import org.apache.commons.lang.exception.ExceptionUtils; -import org.junit.Rule; -import org.junit.rules.TestName; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.junit.jupiter.api.TestInfo; import org.rnorth.ducttape.Preconditions; import java.io.InputStream; import java.util.Arrays; -import static org.rnorth.visibleassertions.VisibleAssertions.fail; -import static org.rnorth.visibleassertions.VisibleAssertions.pass; +import static org.assertj.core.api.Assertions.fail; public abstract class AbstractStatementTest { - @Rule - public TestName testName = new TestName(); + private final TestInfo testInfo; - protected void assertStatement(Statement statement) { + AbstractStatementTest(TestInfo testInfo) { + this.testInfo = testInfo; + } + protected void assertStatement(Statement statement) { + String testName = testInfo.getTestMethod().get().getName(); String[] expectedLines = new String[0]; try { - String path = "fixtures/statements/" + getClass().getSimpleName() + "/" + testName.getMethodName(); + String path = "fixtures/statements/" + getClass().getSimpleName() + "/" + testName; InputStream inputStream = getClass().getClassLoader().getResourceAsStream(path); Preconditions.check("inputStream is null for path " + path, inputStream != null); @@ -31,7 +32,7 @@ protected void assertStatement(Statement statement) { IOUtils.closeQuietly(inputStream); expectedLines = StringUtils.chomp(content.replaceAll("\r\n", "\n").trim()).split("\n"); } catch (Exception e) { - fail("can't load fixture '" + testName.getMethodName() + "'\n" + ExceptionUtils.getFullStackTrace(e)); + fail("can't load fixture '" + testName + "'\n" + ExceptionUtils.getStackTrace(e)); } StringBuilder builder = new StringBuilder(); @@ -39,7 +40,9 @@ protected void assertStatement(Statement statement) { String[] resultLines = StringUtils.chomp(builder.toString().trim()).split("\n"); if (expectedLines.length != resultLines.length) { - fail("number of lines is not the same. Expected " + expectedLines.length + " but got " + resultLines.length); + fail( + "number of lines is not the same. Expected " + expectedLines.length + " but got " + resultLines.length + ); } if (!Arrays.equals(expectedLines, resultLines)) { @@ -59,12 +62,9 @@ protected void assertStatement(Statement statement) { failureBuilder.append(expectedLine); failureBuilder.append(">\n"); } - } fail(failureBuilder.toString()); } - - pass(testName.getMethodName()); } } diff --git a/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/KeyValuesStatementTest.java b/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/KeyValuesStatementTest.java index 6add9d462f2..3ccd78faf7f 100644 --- a/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/KeyValuesStatementTest.java +++ b/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/KeyValuesStatementTest.java @@ -1,46 +1,53 @@ package org.testcontainers.images.builder.dockerfile.statement; import com.google.common.collect.ImmutableMap; -import org.junit.Test; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; import java.util.Collections; -public class KeyValuesStatementTest extends AbstractStatementTest { +class KeyValuesStatementTest extends AbstractStatementTest { + + KeyValuesStatementTest(TestInfo testInfo) { + super(testInfo); + } @Test - public void multilineTest() throws Exception { - ImmutableMap pairs = ImmutableMap.builder() - .put("line1", "1") - .put("line2", "2") - .put("line3", "3") - .build(); + void multilineTest() throws Exception { + ImmutableMap pairs = ImmutableMap + .builder() + .put("line1", "1") + .put("line2", "2") + .put("line3", "3") + .build(); assertStatement(new KeyValuesStatement("TEST", pairs)); } @Test - public void keyWithSpacesTest() throws Exception { + void keyWithSpacesTest() throws Exception { assertStatement(new KeyValuesStatement("TEST", Collections.singletonMap("key with spaces", "1"))); } @Test - public void keyWithNewLinesTest() throws Exception { + void keyWithNewLinesTest() throws Exception { assertStatement(new KeyValuesStatement("TEST", Collections.singletonMap("key\nwith\nnewlines", "1"))); } @Test - public void keyWithTabsTest() throws Exception { + void keyWithTabsTest() throws Exception { assertStatement(new KeyValuesStatement("TEST", Collections.singletonMap("key\twith\ttab", "1"))); } @Test - public void valueIsEscapedTest() throws Exception { - ImmutableMap pairs = ImmutableMap.builder() - .put("1", "value with spaces") - .put("2", "value\nwith\nnewlines") - .put("3", "value\twith\ttab") - .build(); + void valueIsEscapedTest() throws Exception { + ImmutableMap pairs = ImmutableMap + .builder() + .put("1", "value with spaces") + .put("2", "value\nwith\nnewlines") + .put("3", "value\twith\ttab") + .build(); assertStatement(new KeyValuesStatement("TEST", pairs)); } -} \ No newline at end of file +} diff --git a/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/MultiArgsStatementTest.java b/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/MultiArgsStatementTest.java index 3b36af8861e..06d8fccccc0 100644 --- a/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/MultiArgsStatementTest.java +++ b/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/MultiArgsStatementTest.java @@ -1,16 +1,21 @@ package org.testcontainers.images.builder.dockerfile.statement; -import org.junit.Test; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; -public class MultiArgsStatementTest extends AbstractStatementTest { +class MultiArgsStatementTest extends AbstractStatementTest { + + MultiArgsStatementTest(TestInfo testInfo) { + super(testInfo); + } @Test - public void simpleTest() throws Exception { + void simpleTest() { assertStatement(new MultiArgsStatement("TEST", "a", "b", "c")); } @Test - public void multilineTest() throws Exception { + void multilineTest() { assertStatement(new MultiArgsStatement("TEST", "some\nmultiline\nargument")); } -} \ No newline at end of file +} diff --git a/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/RawStatementTest.java b/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/RawStatementTest.java index a600451bd71..d6964e0e5d7 100644 --- a/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/RawStatementTest.java +++ b/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/RawStatementTest.java @@ -1,12 +1,16 @@ package org.testcontainers.images.builder.dockerfile.statement; -import org.junit.Test; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; -public class RawStatementTest extends AbstractStatementTest { +class RawStatementTest extends AbstractStatementTest { + + RawStatementTest(TestInfo testInfo) { + super(testInfo); + } @Test - public void simpleTest() throws Exception { + void simpleTest() throws Exception { assertStatement(new RawStatement("TEST", "value\nas\t\\\nis")); } - -} \ No newline at end of file +} diff --git a/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/SingleArgumentStatementTest.java b/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/SingleArgumentStatementTest.java index 75d290b240d..cf446ef1018 100644 --- a/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/SingleArgumentStatementTest.java +++ b/core/src/test/java/org/testcontainers/images/builder/dockerfile/statement/SingleArgumentStatementTest.java @@ -1,16 +1,21 @@ package org.testcontainers.images.builder.dockerfile.statement; -import org.junit.Test; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; -public class SingleArgumentStatementTest extends AbstractStatementTest { +class SingleArgumentStatementTest extends AbstractStatementTest { + + SingleArgumentStatementTest(TestInfo testInfo) { + super(testInfo); + } @Test - public void simpleTest() throws Exception { + void simpleTest() throws Exception { assertStatement(new SingleArgumentStatement("TEST", "hello")); } @Test - public void multilineTest() throws Exception { + void multilineTest() throws Exception { assertStatement(new SingleArgumentStatement("TEST", "hello\nworld")); } -} \ No newline at end of file +} diff --git a/core/src/test/java/org/testcontainers/junit/BaseComposeTest.java b/core/src/test/java/org/testcontainers/junit/BaseComposeTest.java new file mode 100644 index 00000000000..334b69740b7 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/BaseComposeTest.java @@ -0,0 +1,85 @@ +package org.testcontainers.junit; + +import com.github.dockerjava.api.model.Network; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.utility.TestEnvironment; +import redis.clients.jedis.Jedis; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +public abstract class BaseComposeTest { + + protected static final int REDIS_PORT = 6379; + + protected abstract ComposeContainer getEnvironment(); + + private List existingNetworks = new ArrayList<>(); + + @BeforeAll + public static void checkVersion() { + Assumptions.assumeTrue(TestEnvironment.dockerApiAtLeast("1.22")); + } + + @Test + void simpleTest() { + Jedis jedis = new Jedis( + getEnvironment().getServiceHost("redis-1", REDIS_PORT), + getEnvironment().getServicePort("redis-1", REDIS_PORT) + ); + + jedis.incr("test"); + jedis.incr("test"); + jedis.incr("test"); + + assertThat(jedis.get("test")).as("A redis instance defined in compose can be used in isolation").isEqualTo("3"); + } + + @Test + void secondTest() { + // used in manual checking for cleanup in between tests + Jedis jedis = new Jedis( + getEnvironment().getServiceHost("redis-1", REDIS_PORT), + getEnvironment().getServicePort("redis-1", REDIS_PORT) + ); + + jedis.incr("test"); + jedis.incr("test"); + jedis.incr("test"); + + assertThat(jedis.get("test")).as("Tests use fresh container instances").isEqualTo("3"); + // if these end up using the same container one of the test methods will fail. + // However, @Rule creates a separate ComposeContainer instance per test, so this just shouldn't happen + } + + @BeforeEach + public void captureNetworks() { + existingNetworks.addAll(findAllNetworks()); + } + + @AfterEach + public void verifyNoNetworks() { + assertThat(findAllNetworks()).as("The networks").isEqualTo(existingNetworks); + } + + private List findAllNetworks() { + return DockerClientFactory + .instance() + .client() + .listNetworksCmd() + .exec() + .stream() + .map(Network::getName) + .sorted() + .collect(Collectors.toList()); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/BaseDockerComposeTest.java b/core/src/test/java/org/testcontainers/junit/BaseDockerComposeTest.java index 8024eac22a4..58459c2d807 100644 --- a/core/src/test/java/org/testcontainers/junit/BaseDockerComposeTest.java +++ b/core/src/test/java/org/testcontainers/junit/BaseDockerComposeTest.java @@ -1,9 +1,10 @@ package org.testcontainers.junit; import com.github.dockerjava.api.model.Network; -import org.jetbrains.annotations.NotNull; -import org.junit.*; -import org.rnorth.ducttape.unreliables.Unreliables; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.DockerComposeContainer; import org.testcontainers.utility.TestEnvironment; @@ -11,13 +12,10 @@ import java.util.ArrayList; import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; -import static org.hamcrest.CoreMatchers.is; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThat; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; /** * Created by rnorth on 21/05/2016. @@ -30,65 +28,61 @@ public abstract class BaseDockerComposeTest { private List existingNetworks = new ArrayList<>(); - @BeforeClass + @BeforeAll public static void checkVersion() { - Assume.assumeTrue(TestEnvironment.dockerApiAtLeast("1.22")); + assumeThat(TestEnvironment.dockerApiAtLeast("1.22")).isTrue(); } @Test - public void simpleTest() { - Jedis jedis = new Jedis(getEnvironment().getServiceHost("redis_1", REDIS_PORT), getEnvironment().getServicePort("redis_1", REDIS_PORT)); - - // TODO: remove following resolution of #160 - Unreliables.retryUntilSuccess(10, TimeUnit.SECONDS, getLivenessCheck(jedis)); + void simpleTest() { + Jedis jedis = new Jedis( + getEnvironment().getServiceHost("redis_1", REDIS_PORT), + getEnvironment().getServicePort("redis_1", REDIS_PORT) + ); jedis.incr("test"); jedis.incr("test"); jedis.incr("test"); - assertEquals("A redis instance defined in compose can be used in isolation", "3", jedis.get("test")); + assertThat(jedis.get("test")).as("A redis instance defined in compose can be used in isolation").isEqualTo("3"); } @Test - public void secondTest() { + void secondTest() { // used in manual checking for cleanup in between tests - Jedis jedis = new Jedis(getEnvironment().getServiceHost("redis_1", REDIS_PORT), getEnvironment().getServicePort("redis_1", REDIS_PORT)); - - // TODO: remove following resolution of #160 - Unreliables.retryUntilSuccess(10, TimeUnit.SECONDS, getLivenessCheck(jedis)); + Jedis jedis = new Jedis( + getEnvironment().getServiceHost("redis_1", REDIS_PORT), + getEnvironment().getServicePort("redis_1", REDIS_PORT) + ); jedis.incr("test"); jedis.incr("test"); jedis.incr("test"); - assertEquals("Tests use fresh container instances", "3", jedis.get("test")); + assertThat(jedis.get("test")).as("Tests use fresh container instances").isEqualTo("3"); // if these end up using the same container one of the test methods will fail. // However, @Rule creates a separate DockerComposeContainer instance per test, so this just shouldn't happen } - @Before + @BeforeEach public void captureNetworks() { - existingNetworks.addAll(findAllNetworks()); + existingNetworks.addAll(findAllNetworks()); } - @After + @AfterEach public void verifyNoNetworks() { - assertThat("The networks", findAllNetworks(), is(existingNetworks)); + assertThat(findAllNetworks()).as("The networks").isEqualTo(existingNetworks); } private List findAllNetworks() { - return DockerClientFactory.instance().client().listNetworksCmd().exec().stream() - .map(Network::getName) - .sorted() - .collect(Collectors.toList()); - } - - @NotNull - private Callable getLivenessCheck(Jedis jedis) { - return () -> { - jedis.connect(); - jedis.ping(); - return true; - }; + return DockerClientFactory + .instance() + .client() + .listNetworksCmd() + .exec() + .stream() + .map(Network::getName) + .sorted() + .collect(Collectors.toList()); } } diff --git a/core/src/test/java/org/testcontainers/junit/ComposeContainerOverrideTest.java b/core/src/test/java/org/testcontainers/junit/ComposeContainerOverrideTest.java new file mode 100644 index 00000000000..5065aaf62b2 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ComposeContainerOverrideTest.java @@ -0,0 +1,79 @@ +package org.testcontainers.junit; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.ContainerState; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; + +import static org.assertj.core.api.Assertions.assertThat; + +class ComposeContainerOverrideTest { + + private static final File BASE = new File("src/test/resources/compose-override/compose.yml"); + + private static final File OVERRIDE = new File("src/test/resources/compose-override/compose-override.yml"); + + @Test + void readEnvironment() { + try ( + ComposeContainer compose = new ComposeContainer(DockerImageName.parse("docker:25.0.5"), BASE) + .withExposedService("redis", 6379) + ) { + compose.start(); + InspectContainerResponse container = compose + .getContainerByServiceName("redis-1") + .map(ContainerState::getContainerInfo) + .get(); + assertThat(container.getConfig().getEnv()).contains("foo=bar"); + } + } + + @Test + void resetEnvironment() { + try ( + ComposeContainer compose = new ComposeContainer(DockerImageName.parse("docker:25.0.5"), BASE, OVERRIDE) + .withExposedService("redis", 6379) + ) { + compose.start(); + InspectContainerResponse container = compose + .getContainerByServiceName("redis-1") + .map(ContainerState::getContainerInfo) + .get(); + assertThat(container.getConfig().getEnv()).doesNotContain("foo=bar"); + } + } + + @Test + void readLabel() { + try ( + ComposeContainer compose = new ComposeContainer(DockerImageName.parse("docker:25.0.5"), BASE) + .withExposedService("redis", 6379) + ) { + compose.start(); + InspectContainerResponse container = compose + .getContainerByServiceName("redis-1") + .map(ContainerState::getContainerInfo) + .get(); + assertThat(container.getConfig().getLabels()).containsKey("label1"); + } + } + + @Test + void readOverriddenLabel() { + try ( + ComposeContainer compose = new ComposeContainer(DockerImageName.parse("docker:25.0.5"), BASE, OVERRIDE) + .withExposedService("redis", 6379) + ) { + compose.start(); + InspectContainerResponse container = compose + .getContainerByServiceName("redis-1") + .map(ContainerState::getContainerInfo) + .get(); + assertThat(container.getConfig().getLabels()).doesNotContainKey("label1"); + assertThat(container.getConfig().getLabels()).containsKey("label2"); + } + } +} diff --git a/core/src/test/java/org/testcontainers/junit/ComposeContainerPortViaEnvTest.java b/core/src/test/java/org/testcontainers/junit/ComposeContainerPortViaEnvTest.java new file mode 100644 index 00000000000..f0a8873f8f3 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ComposeContainerPortViaEnvTest.java @@ -0,0 +1,27 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.AutoClose; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; + +class ComposeContainerPortViaEnvTest extends BaseComposeTest { + + @AutoClose + public ComposeContainer environment = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + new File("src/test/resources/v2-compose-test-port-via-env.yml") + ) + .withExposedService("redis-1", REDIS_PORT) + .withEnv("REDIS_PORT", String.valueOf(REDIS_PORT)); + + ComposeContainerPortViaEnvTest() { + this.environment.start(); + } + + @Override + protected ComposeContainer getEnvironment() { + return environment; + } +} diff --git a/core/src/test/java/org/testcontainers/junit/ComposeContainerScalingTest.java b/core/src/test/java/org/testcontainers/junit/ComposeContainerScalingTest.java new file mode 100644 index 00000000000..3ef559bcda3 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ComposeContainerScalingTest.java @@ -0,0 +1,60 @@ +package org.testcontainers.junit; + +import org.assertj.core.api.Assumptions; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.TestEnvironment; +import redis.clients.jedis.Jedis; + +import java.io.File; + +import static org.assertj.core.api.Assertions.assertThat; + +class ComposeContainerScalingTest { + + private static final int REDIS_PORT = 6379; + + private Jedis[] clients = new Jedis[3]; + + @BeforeAll + public static void checkVersion() { + Assumptions.assumeThat(TestEnvironment.dockerApiAtLeast("1.22")).isTrue(); + } + + @AutoClose + public ComposeContainer environment = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + new File("src/test/resources/composev2/scaled-compose-test.yml") + ) + .withScaledService("redis", 3) + .withExposedService("redis", REDIS_PORT) // implicit '-1' + .withExposedService("redis-2", REDIS_PORT) // explicit service index + .withExposedService("redis", 3, REDIS_PORT); // explicit service index via parameter + + ComposeContainerScalingTest() { + environment.start(); + } + + @BeforeEach + public void setupClients() { + for (int i = 0; i < 3; i++) { + String name = String.format("redis-%d", i + 1); + + clients[i] = + new Jedis(environment.getServiceHost(name, REDIS_PORT), environment.getServicePort(name, REDIS_PORT)); + } + } + + @Test + void simpleTest() { + for (int i = 0; i < 3; i++) { + clients[i].incr("somekey"); + + assertThat(clients[i].get("somekey")).as("Each redis instance is separate").isEqualTo("1"); + } + } +} diff --git a/core/src/test/java/org/testcontainers/junit/ComposeContainerTest.java b/core/src/test/java/org/testcontainers/junit/ComposeContainerTest.java new file mode 100644 index 00000000000..9f3299ab441 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ComposeContainerTest.java @@ -0,0 +1,96 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.ContainerState; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Collections; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class ComposeContainerTest extends BaseComposeTest { + + // composeContainerConstructor { + public ComposeContainer environment = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + new File("src/test/resources/composev2/compose-test.yml") + ) + .withExposedService("redis-1", REDIS_PORT) + .withExposedService("db-1", 3306); + + // } + + ComposeContainerTest() { + environment.start(); + } + + @Override + protected ComposeContainer getEnvironment() { + return environment; + } + + @Test + void testGetServiceHostAndPort() { + // getServiceHostAndPort { + String serviceHost = environment.getServiceHost("redis-1", REDIS_PORT); + int serviceWithInstancePort = environment.getServicePort("redis-1", REDIS_PORT); + // } + + assertThat(serviceHost).as("Service host is not blank").isNotBlank(); + assertThat(serviceWithInstancePort).as("Port is set for service with instance number").isNotNull(); + + int serviceWithoutInstancePort = environment.getServicePort("redis", REDIS_PORT); + assertThat(serviceWithoutInstancePort).as("Port is set for service with instance number").isNotNull(); + assertThat(serviceWithoutInstancePort).as("Service ports are the same").isEqualTo(serviceWithInstancePort); + } + + @Test + void shouldRetrieveContainerByServiceName() { + String existingServiceName = "db-1"; + Optional result = environment.getContainerByServiceName(existingServiceName); + assertThat(result) + .as(String.format("Container should be found by service name %s", existingServiceName)) + .isPresent(); + assertThat(Collections.singletonList(3306)) + .as("Mapped port for result container was wrong, probably wrong container found") + .isEqualTo(result.get().getExposedPorts()); + } + + @Test + void shouldReturnEmptyResultOnNoneExistingService() { + String notExistingServiceName = "db-256"; + Optional result = environment.getContainerByServiceName(notExistingServiceName); + assertThat(result) + .as(String.format("No container should be found under service name %s", notExistingServiceName)) + .isNotPresent(); + } + + @Test + void shouldCreateContainerWhenFileNotPrefixedWithPath() throws IOException { + String validYaml = + "version: '2.2'\n" + + "services:\n" + + " http:\n" + + " build: .\n" + + " image: python:latest\n" + + " ports:\n" + + " - 8080:8080"; + + File filePathNotStartWithDotSlash = new File("docker-compose-test.yml"); + filePathNotStartWithDotSlash.createNewFile(); + filePathNotStartWithDotSlash.deleteOnExit(); + Files.write(filePathNotStartWithDotSlash.toPath(), validYaml.getBytes(StandardCharsets.UTF_8)); + + final ComposeContainer dockerComposeContainer = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + filePathNotStartWithDotSlash + ); + assertThat(dockerComposeContainer).as("Container created using docker compose file").isNotNull(); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/ComposeContainerVolumeRemovalTest.java b/core/src/test/java/org/testcontainers/junit/ComposeContainerVolumeRemovalTest.java new file mode 100644 index 00000000000..096928034d2 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ComposeContainerVolumeRemovalTest.java @@ -0,0 +1,82 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Disabled +class ComposeContainerVolumeRemovalTest { + + public static Stream params() { + return Stream.of(Arguments.of(true, false), Arguments.of(false, true)); + } + + @ParameterizedTest + @MethodSource("params") + void performTest(boolean removeVolumes, boolean shouldVolumesBePresentAfterRunning) { + final File composeFile = new File("src/test/resources/v2-compose-test.yml"); + + final AtomicReference volumeName = new AtomicReference<>(""); + try ( + ComposeContainer environment = new ComposeContainer(DockerImageName.parse("docker:25.0.5"), composeFile) + .withExposedService("redis", 6379) + .withRemoveVolumes(removeVolumes) + .withRemoveImages(ComposeContainer.RemoveImages.ALL) + ) { + environment.start(); + + volumeName.set(volumeNameForRunningContainer("-redis-1")); + final boolean isVolumePresentWhileRunning = isVolumePresent(volumeName.get()); + assertThat(isVolumePresentWhileRunning).as("the container volume is present while running").isEqualTo(true); + } + + await() + .untilAsserted(() -> { + final boolean isVolumePresentAfterRunning = isVolumePresent(volumeName.get()); + assertThat(isVolumePresentAfterRunning) + .as("the container volume is present after running") + .isEqualTo(shouldVolumesBePresentAfterRunning); + }); + } + + private String volumeNameForRunningContainer(final String containerNameSuffix) { + return DockerClientFactory + .instance() + .client() + .listContainersCmd() + .exec() + .stream() + .filter(it -> Stream.of(it.getNames()).anyMatch(name -> name.endsWith(containerNameSuffix))) + .findFirst() + .map(container -> container.getMounts().get(0).getName()) + .orElseThrow(IllegalStateException::new); + } + + private boolean isVolumePresent(final String volumeName) { + Set nameFilter = new LinkedHashSet<>(1); + nameFilter.add(volumeName); + return DockerClientFactory + .instance() + .client() + .listVolumesCmd() + .withFilter("name", nameFilter) + .exec() + .getVolumes() + .stream() + .findFirst() + .isPresent(); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/ComposeContainerWithBuildTest.java b/core/src/test/java/org/testcontainers/junit/ComposeContainerWithBuildTest.java new file mode 100644 index 00000000000..714767914a0 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ComposeContainerWithBuildTest.java @@ -0,0 +1,107 @@ +package org.testcontainers.junit; + +import com.github.dockerjava.api.model.Container; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.rnorth.ducttape.unreliables.Unreliables; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class ComposeContainerWithBuildTest { + + public static Stream params() { + return Stream.of( + Arguments.of(null, true, true), + Arguments.of(ComposeContainer.RemoveImages.LOCAL, false, true), + Arguments.of(ComposeContainer.RemoveImages.ALL, false, false) + ); + } + + @ParameterizedTest + @MethodSource("params") + void performTest( + ComposeContainer.RemoveImages removeMode, + boolean shouldBuiltImageBePresentAfterRunning, + boolean shouldPulledImageBePresentAfterRunning + ) { + final File composeFile = new File("src/test/resources/compose-v2-build-test/docker-compose.yml"); + + final AtomicReference builtImageName = new AtomicReference<>(""); + final AtomicReference pulledImageName = new AtomicReference<>(""); + try ( + ComposeContainer environment = new ComposeContainer(DockerImageName.parse("docker:25.0.5"), composeFile) + .withExposedService("customredis", 6379) + .withBuild(true) + .withRemoveImages(removeMode) + ) { + environment.start(); + + builtImageName.set(imageNameForRunningContainer("-customredis-1")); + final boolean isBuiltImagePresentWhileRunning = isImagePresent(builtImageName.get()); + assertThat(isBuiltImagePresentWhileRunning).as("the built image is present while running").isTrue(); + + pulledImageName.set(imageNameForRunningContainer("-normalredis-1")); + final boolean isPulledImagePresentWhileRunning = isImagePresent(pulledImageName.get()); + assertThat(isPulledImagePresentWhileRunning).as("the pulled image is present while running").isTrue(); + } + + Unreliables.retryUntilSuccess( + 10, + TimeUnit.SECONDS, + () -> { + final boolean isBuiltImagePresentAfterRunning = isImagePresent(builtImageName.get()); + assertThat(isBuiltImagePresentAfterRunning) + .as("the built image is not present after running") + .isEqualTo(shouldBuiltImageBePresentAfterRunning); + return null; + } + ); + + Unreliables.retryUntilSuccess( + 10, + TimeUnit.SECONDS, + () -> { + final boolean isPulledImagePresentAfterRunning = isImagePresent(pulledImageName.get()); + assertThat(isPulledImagePresentAfterRunning) + .as("the pulled image is present after running") + .isEqualTo(shouldPulledImageBePresentAfterRunning); + return null; + } + ); + } + + private String imageNameForRunningContainer(final String containerNameSuffix) { + return DockerClientFactory + .instance() + .client() + .listContainersCmd() + .exec() + .stream() + .filter(it -> Stream.of(it.getNames()).anyMatch(name -> name.endsWith(containerNameSuffix))) + .findFirst() + .map(Container::getImage) + .orElseThrow(IllegalStateException::new); + } + + private boolean isImagePresent(final String imageName) { + return DockerClientFactory + .instance() + .client() + .listImagesCmd() + .withFilter("reference", Collections.singletonList(imageName)) + .exec() + .stream() + .findFirst() + .isPresent(); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/ComposeContainerWithCopyFilesTest.java b/core/src/test/java/org/testcontainers/junit/ComposeContainerWithCopyFilesTest.java new file mode 100644 index 00000000000..3568550ca38 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ComposeContainerWithCopyFilesTest.java @@ -0,0 +1,94 @@ +package org.testcontainers.junit; + +import io.restassured.RestAssured; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class ComposeContainerWithCopyFilesTest { + + @Test + void testShouldCopyAllFilesByDefault() throws IOException { + try ( + ComposeContainer environment = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + new File("src/test/resources/compose-file-copy-inclusions/compose.yml") + ) + .withExposedService("app", 8080) + ) { + environment.start(); + + String response = readStringFromURL(environment); + assertThat(response).isEqualTo("MY_ENV_VARIABLE: override"); + } + } + + @Test + void testWithFileCopyInclusionUsingFilePath() throws IOException { + try ( + ComposeContainer environment = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + new File("src/test/resources/compose-file-copy-inclusions/compose-root-only.yml") + ) + .withExposedService("app", 8080) + .withCopyFilesInContainer("Dockerfile", "EnvVariableRestEndpoint.java", ".env") + ) { + environment.start(); + + String response = readStringFromURL(environment); + + // The `test/.env` file is not copied, now so we get the original value + assertThat(response).isEqualTo("MY_ENV_VARIABLE: original"); + } + } + + @Test + void testWithFileCopyInclusionUsingDirectoryPath() throws IOException { + try ( + // composeContainerWithCopyFiles { + ComposeContainer environment = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + new File("src/test/resources/compose-file-copy-inclusions/compose-test-only.yml") + ) + .withExposedService("app", 8080) + .withCopyFilesInContainer("Dockerfile", "EnvVariableRestEndpoint.java", "test") + // } + ) { + environment.start(); + + String response = readStringFromURL(environment); + // The test directory (with its contents) is copied, so we get the override + assertThat(response).isEqualTo("MY_ENV_VARIABLE: override"); + } + } + + @Test + void testShouldNotBeAbleToStartIfNeededEnvFileIsNotCopied() { + try ( + ComposeContainer environment = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + new File("src/test/resources/compose-file-copy-inclusions/compose-test-only.yml") + ) + .withExposedService("app", 8080) + .withCopyFilesInContainer("Dockerfile", "EnvVariableRestEndpoint.java") + ) { + assertThatExceptionOfType(ContainerLaunchException.class) + .isThrownBy(environment::start) + .withMessageContaining("Container startup failed for image docker"); + } + } + + private static String readStringFromURL(ComposeContainer container) throws IOException { + Integer servicePort = container.getServicePort("app-1", 8080); + String serviceHost = container.getServiceHost("app-1", 8080); + String requestURL = "http://" + serviceHost + ":" + servicePort + "/env"; + return RestAssured.get(requestURL).thenReturn().body().asString(); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/ComposeContainerWithOptionsTest.java b/core/src/test/java/org/testcontainers/junit/ComposeContainerWithOptionsTest.java new file mode 100644 index 00000000000..800da177137 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ComposeContainerWithOptionsTest.java @@ -0,0 +1,80 @@ +package org.testcontainers.junit; + +import com.google.common.collect.ImmutableSet; +import org.assertj.core.api.Assumptions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.utility.CommandLine; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.util.Set; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests the options associated with the docker-compose command. + */ +class ComposeContainerWithOptionsTest { + + public static Stream params() { + return Stream.of( + // Test the happy day case. The compatibility option should be accepted by docker-compose. + Arguments.of( + new File("src/test/resources/compose-options-test/with-deploy-block.yml"), + false, + ImmutableSet.of("--compatibility"), + false + ), + // Test with flags absent. Docker compose will warn but continue, ignoring the deploy block. + Arguments.of( + new File("src/test/resources/compose-options-test/with-deploy-block.yml"), + false, + ImmutableSet.of(""), + false + ), + // Test with a bad option. Compose will complain. + Arguments.of( + new File("src/test/resources/compose-options-test/with-deploy-block.yml"), + false, + ImmutableSet.of("--bad-option"), + true + ), + // Local compose + Arguments.of( + new File("src/test/resources/compose-options-test/with-deploy-block.yml"), + true, + ImmutableSet.of("--compatibility"), + false + ) + ); + } + + @ParameterizedTest(name = "docker-compose test [compose file: {0}, local: {1}, options: {2}, expected result: {3}]") + @MethodSource("params") + void performTest(File composeFile, boolean localMode, Set options, boolean expectError) { + ComposeContainer environment; + if (localMode) { + Assumptions + .assumeThat(CommandLine.executableExists(ComposeContainer.COMPOSE_EXECUTABLE)) + .as("docker executable exists") + .isTrue(); + environment = new ComposeContainer(composeFile).withOptions(options.stream().toArray(String[]::new)); + } else { + environment = + new ComposeContainer(DockerImageName.parse("docker:25.0.2"), composeFile) + .withOptions(options.stream().toArray(String[]::new)); + } + + try { + environment.start(); + assertThat(expectError).isFalse(); + environment.stop(); + } catch (Exception e) { + assertThat(expectError).isTrue(); + } + } +} diff --git a/core/src/test/java/org/testcontainers/junit/ComposeContainerWithWaitStrategiesTest.java b/core/src/test/java/org/testcontainers/junit/ComposeContainerWithWaitStrategiesTest.java new file mode 100644 index 00000000000..d824226104e --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ComposeContainerWithWaitStrategiesTest.java @@ -0,0 +1,61 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +class ComposeContainerWithWaitStrategiesTest { + + private static final int REDIS_PORT = 6379; + + @Test + void testComposeContainerConstructor() { + try ( + // composeContainerWithCombinedWaitStrategies { + ComposeContainer compose = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + new File("src/test/resources/composev2/compose-test.yml") + ) + .withExposedService("redis-1", REDIS_PORT, Wait.forSuccessfulCommand("redis-cli ping")) + .withExposedService("db-1", 3306, Wait.forLogMessage(".*ready for connections.*\\n", 1)) + // } + ) { + compose.start(); + containsStartedServices(compose, "redis-1", "db-1"); + } + } + + @Test + void testComposeContainerWaitForPortWithTimeout() { + try ( + // composeContainerWaitForPortWithTimeout { + ComposeContainer compose = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + new File("src/test/resources/composev2/compose-test.yml") + ) + .withExposedService( + "redis-1", + REDIS_PORT, + Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(30)) + ) + // } + ) { + compose.start(); + containsStartedServices(compose, "redis-1"); + } + } + + private void containsStartedServices(ComposeContainer compose, String... expectedServices) { + for (String serviceName : expectedServices) { + assertThat(compose.getContainerByServiceName(serviceName)) + .as("Container should be found by service name %s", serviceName) + .isPresent(); + } + } +} diff --git a/core/src/test/java/org/testcontainers/junit/ComposeErrorHandlingTest.java b/core/src/test/java/org/testcontainers/junit/ComposeErrorHandlingTest.java new file mode 100644 index 00000000000..df4338ec236 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ComposeErrorHandlingTest.java @@ -0,0 +1,28 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +class ComposeErrorHandlingTest { + + @Test + void simpleTest() { + assertThat( + catchThrowable(() -> { + ComposeContainer environment = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + new File("src/test/resources/invalid-compose.yml") + ) + .withExposedService("something", 123); + }) + ) + .as("starting with an invalid docker-compose file throws an exception") + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/ComposePassthroughTest.java b/core/src/test/java/org/testcontainers/junit/ComposePassthroughTest.java new file mode 100644 index 00000000000..38d0139c1cc --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ComposePassthroughTest.java @@ -0,0 +1,66 @@ +package org.testcontainers.junit; + +import org.assertj.core.api.Assumptions; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.ContainerState; +import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.TestEnvironment; + +import java.io.File; +import java.util.Arrays; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; + +class ComposePassthroughTest { + + private final TestWaitStrategy waitStrategy = new TestWaitStrategy(); + + @BeforeAll + public static void checkVersion() { + Assumptions.assumeThat(TestEnvironment.dockerApiAtLeast("1.22")).isTrue(); + } + + @AutoClose + public ComposeContainer compose = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + new File("src/test/resources/v2-compose-test-passthrough.yml") + ) + .withEnv("foo", "bar") + .withExposedService("alpine-1", 3000, waitStrategy); + + ComposePassthroughTest() { + compose.start(); + } + + @Test + void testContainerInstanceProperties() { + final ContainerState container = waitStrategy.getContainer(); + + //check environment variable was set + assertThat(Arrays.asList(Objects.requireNonNull(container.getContainerInfo().getConfig().getEnv()))) + .as("Environment variable set correctly") + .containsOnlyOnce("bar=bar"); + + //check other container properties + assertThat(container.getContainerId()).as("Container id is not null").isNotNull(); + assertThat(container.getMappedPort(3000)).as("Port mapped").isNotNull(); + assertThat(container.getExposedPorts()).containsExactly(3000); + } + + /* + * WaitStrategy is the only class that has access to the DockerComposeServiceInstance reference + * Using a custom WaitStrategy to expose the reference for testability + */ + class TestWaitStrategy extends HostPortWaitStrategy { + + @SuppressWarnings("unchecked") + public ContainerState getContainer() { + return this.waitStrategyTarget; + } + } +} diff --git a/core/src/test/java/org/testcontainers/junit/ComposeWaitStrategyTest.java b/core/src/test/java/org/testcontainers/junit/ComposeWaitStrategyTest.java new file mode 100644 index 00000000000..371c70dafac --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ComposeWaitStrategyTest.java @@ -0,0 +1,114 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.assertj.core.api.Assertions.fail; + +class ComposeWaitStrategyTest { + + private static final int REDIS_PORT = 6379; + + private ComposeContainer environment; + + @BeforeEach + public final void setUp() { + environment = + new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + new File("src/test/resources/composev2/compose-test.yml") + ); + } + + @AfterEach + public final void cleanUp() { + environment.stop(); + } + + @Test + void testWaitOnListeningPort() { + environment.withExposedService("redis-1", REDIS_PORT, Wait.forListeningPort()); + + try { + environment.start(); + } catch (RuntimeException e) { + fail("Docker compose should start after waiting for listening port with failed with: " + e); + } + } + + @Test + void testWaitOnMultipleStrategiesPassing() { + environment + .withExposedService("redis-1", REDIS_PORT, Wait.forListeningPort()) + .withExposedService("db-1", 3306, Wait.forLogMessage(".*ready for connections.*\\s", 1)) + .withTailChildContainers(true); + + try { + environment.start(); + } catch (RuntimeException e) { + fail("Docker compose should start after waiting for listening port with failed with: " + e); + } + } + + @Test + void testWaitingFails() { + environment.withExposedService( + "redis-1", + REDIS_PORT, + Wait.forHttp("/test").withStartupTimeout(Duration.ofSeconds(10)) + ); + assertThat(catchThrowable(() -> environment.start())) + .as("waiting on an invalid http path times out") + .isInstanceOf(RuntimeException.class); + } + + @Test + void testWaitOnOneOfMultipleStrategiesFailing() { + environment + .withExposedService( + "redis-1", + REDIS_PORT, + Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(10)) + ) + .waitingFor( + "db-1", + Wait.forLogMessage(".*test test test.*\\s", 1).withStartupTimeout(Duration.ofSeconds(10)) + ) + .withTailChildContainers(true); + + assertThat(catchThrowable(() -> environment.start())) + .as("waiting on one failing strategy to time out") + .isInstanceOf(RuntimeException.class); + } + + @Test + void testWaitingForNonexistentServices() { + String existentServiceName = "db-1"; + String nonexistentServiceName1 = "some-nonexistent_service-1"; + String nonexistentServiceName2 = "some-nonexistent_service-2"; + WaitStrategy someWaitStrategy = Mockito.mock(WaitStrategy.class); + + environment + .waitingFor(existentServiceName, someWaitStrategy) + .waitingFor(nonexistentServiceName1, someWaitStrategy) + .waitingFor(nonexistentServiceName2, someWaitStrategy); + + Throwable thrownWhenRequestedToWaitForNonexistentService = catchThrowable(environment::start); + + assertThat(thrownWhenRequestedToWaitForNonexistentService) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(nonexistentServiceName1, nonexistentServiceName2) + .hasMessageNotContaining(existentServiceName); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/ComposeWithIdentifierTest.java b/core/src/test/java/org/testcontainers/junit/ComposeWithIdentifierTest.java new file mode 100644 index 00000000000..0a5c0b868c8 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ComposeWithIdentifierTest.java @@ -0,0 +1,27 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.AutoClose; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; + +class ComposeWithIdentifierTest extends BaseComposeTest { + + @AutoClose + public ComposeContainer environment = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + "TEST", + new File("src/test/resources/v2-compose-test.yml") + ) + .withExposedService("redis-1", REDIS_PORT); + + ComposeWithIdentifierTest() { + environment.start(); + } + + @Override + protected ComposeContainer getEnvironment() { + return this.environment; + } +} diff --git a/core/src/test/java/org/testcontainers/junit/ComposeWithNetworkTest.java b/core/src/test/java/org/testcontainers/junit/ComposeWithNetworkTest.java new file mode 100644 index 00000000000..9fdf55a83a1 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ComposeWithNetworkTest.java @@ -0,0 +1,26 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.AutoClose; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; + +class ComposeWithNetworkTest extends BaseComposeTest { + + @AutoClose + public ComposeContainer environment = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + new File("src/test/resources/v2-compose-test-with-network.yml") + ) + .withExposedService("redis-1", REDIS_PORT); + + ComposeWithNetworkTest() { + environment.start(); + } + + @Override + protected ComposeContainer getEnvironment() { + return environment; + } +} diff --git a/core/src/test/java/org/testcontainers/junit/CopyFileToContainerTest.java b/core/src/test/java/org/testcontainers/junit/CopyFileToContainerTest.java new file mode 100644 index 00000000000..1acac36e6ad --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/CopyFileToContainerTest.java @@ -0,0 +1,97 @@ +package org.testcontainers.junit; + +import com.google.common.io.Files; +import com.google.common.io.Resources; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.TestImages; +import org.testcontainers.containers.BindMode; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.SelinuxContext; +import org.testcontainers.utility.MountableFile; + +import java.io.File; +import java.io.IOException; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class CopyFileToContainerTest { + + private static String destinationOnHost; + + private static String directoryInContainer = "/tmp/mappable-resource/"; + + private static String fileName = "test-resource.txt"; + + @BeforeEach + public void setup() throws IOException { + destinationOnHost = File.createTempFile("testcontainers", null).getAbsolutePath(); + } + + @Test + void checkFileCopied() throws IOException, InterruptedException { + try ( + // copyToContainer { + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withCommand("sleep", "3000") + .withCopyFileToContainer( + MountableFile.forClasspathResource("/mappable-resource/"), + directoryInContainer + ) + // } + ) { + container.start(); + String filesList = container.execInContainer("ls", directoryInContainer).getStdout(); + assertThat(filesList).as("file list contains the file").contains(fileName); + + // copyFileFromContainer { + container.copyFileFromContainer(directoryInContainer + fileName, destinationOnHost); + // } + } + + assertThat(Files.toByteArray(new File(destinationOnHost))) + .isEqualTo(Resources.toByteArray(getClass().getResource("/mappable-resource/" + fileName))); + } + + @Test + void shouldUseCopyForReadOnlyClasspathResources() throws Exception { + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withCommand("sleep", "3000") + .withClasspathResourceMapping("/mappable-resource/", directoryInContainer, BindMode.READ_ONLY) + ) { + container.start(); + String filesList = container.execInContainer("ls", "/tmp/mappable-resource").getStdout(); + assertThat(filesList).as("file list contains the file").contains(fileName); + } + } + + @Test + void shouldUseCopyOnlyWithReadOnlyClasspathResources() { + String resource = "/test_copy_to_container.txt"; + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withClasspathResourceMapping(resource, "/readOnly", BindMode.READ_ONLY) + .withClasspathResourceMapping(resource, "/readOnlyShared", BindMode.READ_ONLY, SelinuxContext.SHARED) + .withClasspathResourceMapping(resource, "/readWrite", BindMode.READ_WRITE); + + Map copyMap = container.getCopyToFileContainerPathMap(); + assertThat(copyMap).as("uses copy for read-only").containsValue("/readOnly"); + assertThat(copyMap).as("uses copy for read-only with Selinux").containsValue("/readOnlyShared"); + assertThat(copyMap).as("uses mount for read-write").doesNotContainValue("/readWrite"); + } + + @Test + void shouldCreateFoldersStructureWithCopy() throws Exception { + String resource = "/test_copy_to_container.txt"; + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withCommand("sleep", "3000") + .withClasspathResourceMapping(resource, "/a/b/c/file", BindMode.READ_ONLY) + ) { + container.start(); + String filesList = container.execInContainer("ls", "/a/b/c/").getStdout(); + assertThat(filesList).as("file list contains the file").contains("file"); + } + } +} diff --git a/core/src/test/java/org/testcontainers/junit/DependenciesTest.java b/core/src/test/java/org/testcontainers/junit/DependenciesTest.java new file mode 100644 index 00000000000..cbb305c30f8 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/DependenciesTest.java @@ -0,0 +1,161 @@ +package org.testcontainers.junit; + +import lombok.Getter; +import org.junit.jupiter.api.Test; +import org.testcontainers.TestImages; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.lifecycle.Startables; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class DependenciesTest { + + @Test + void shouldWorkWithSimpleDependency() { + InvocationCountingStartable startable = new InvocationCountingStartable(); + + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + .dependsOn(startable) + ) { + container.start(); + } + + assertThat(startable.getStartInvocationCount().intValue()).as("Started once").isEqualTo(1); + assertThat(startable.getStopInvocationCount().intValue()).as("Does not trigger .stop()").isZero(); + } + + @Test + void shouldWorkWithMultipleDependencies() { + InvocationCountingStartable startable1 = new InvocationCountingStartable(); + InvocationCountingStartable startable2 = new InvocationCountingStartable(); + + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + .dependsOn(startable1, startable2) + ) { + container.start(); + } + + assertThat(startable1.getStartInvocationCount().intValue()).as("Startable1 started once").isEqualTo(1); + assertThat(startable2.getStartInvocationCount().intValue()).as("Startable2 started once").isEqualTo(1); + } + + @Test + void shouldStartEveryTime() { + InvocationCountingStartable startable = new InvocationCountingStartable(); + + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + .dependsOn(startable) + ) { + container.start(); + container.stop(); + + container.start(); + container.stop(); + + container.start(); + } + + assertThat(startable.getStartInvocationCount().intValue()).as("Started multiple times").isEqualTo(3); + assertThat(startable.getStopInvocationCount().intValue()).as("Does not trigger .stop()").isZero(); + } + + @Test + void shouldStartTransitiveDependencies() { + InvocationCountingStartable transitiveOfTransitiveStartable = new InvocationCountingStartable(); + InvocationCountingStartable transitiveStartable = new InvocationCountingStartable(); + transitiveStartable.getDependencies().add(transitiveOfTransitiveStartable); + + InvocationCountingStartable startable = new InvocationCountingStartable(); + startable.getDependencies().add(transitiveStartable); + + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + .dependsOn(startable) + ) { + container.start(); + container.stop(); + } + + assertThat(startable.getStartInvocationCount().intValue()).as("Root started").isEqualTo(1); + assertThat(transitiveStartable.getStartInvocationCount().intValue()).as("Transitive started").isEqualTo(1); + assertThat(transitiveOfTransitiveStartable.getStartInvocationCount().intValue()) + .as("Transitive of transitive started") + .isEqualTo(1); + } + + @Test + void shouldHandleDiamondDependencies() throws Exception { + InvocationCountingStartable a = new InvocationCountingStartable(); + InvocationCountingStartable b = new InvocationCountingStartable(); + InvocationCountingStartable c = new InvocationCountingStartable(); + InvocationCountingStartable d = new InvocationCountingStartable(); + // / b \ + // a d + // \ c / + b.getDependencies().add(a); + c.getDependencies().add(a); + + d.getDependencies().add(b); + d.getDependencies().add(c); + + Startables.deepStart(Stream.of(d)).get(1, TimeUnit.SECONDS); + + assertThat(a.getStartInvocationCount().intValue()).as("A started").isEqualTo(1); + assertThat(b.getStartInvocationCount().intValue()).as("B started").isEqualTo(1); + assertThat(c.getStartInvocationCount().intValue()).as("C started").isEqualTo(1); + assertThat(d.getStartInvocationCount().intValue()).as("D started").isEqualTo(1); + } + + @Test + void shouldHandleParallelStream() throws Exception { + List startables = Stream + .generate(InvocationCountingStartable::new) + .limit(10) + .collect(Collectors.toList()); + + for (int i = 1; i < startables.size(); i++) { + startables.get(0).getDependencies().add(startables.get(i)); + } + + Startables.deepStart(startables.parallelStream()).get(1, TimeUnit.SECONDS); + } + + private static class InvocationCountingStartable implements Startable { + + @Getter + Set dependencies = new HashSet<>(); + + @Getter + AtomicLong startInvocationCount = new AtomicLong(0); + + @Getter + AtomicLong stopInvocationCount = new AtomicLong(0); + + @Override + public void start() { + startInvocationCount.getAndIncrement(); + } + + @Override + public void stop() { + stopInvocationCount.getAndIncrement(); + } + } +} diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeContainerPortViaEnvTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerPortViaEnvTest.java new file mode 100644 index 00000000000..46af463834c --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerPortViaEnvTest.java @@ -0,0 +1,29 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.Disabled; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; + +@Disabled +class DockerComposeContainerPortViaEnvTest extends BaseDockerComposeTest { + + @AutoClose + public DockerComposeContainer environment = new DockerComposeContainer( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/v2-compose-test-port-via-env.yml") + ) + .withExposedService("redis_1", REDIS_PORT) + .withEnv("REDIS_PORT", String.valueOf(REDIS_PORT)); + + DockerComposeContainerPortViaEnvTest() { + environment.start(); + } + + @Override + protected DockerComposeContainer getEnvironment() { + return environment; + } +} diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeContainerScalingTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerScalingTest.java index a44d8de49f4..d5be511f44b 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposeContainerScalingTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerScalingTest.java @@ -1,52 +1,60 @@ package org.testcontainers.junit; -import org.junit.*; +import org.assertj.core.api.Assumptions; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.TestEnvironment; import redis.clients.jedis.Jedis; import java.io.File; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; /** * Created by rnorth on 08/08/2015. */ -public class DockerComposeContainerScalingTest { +class DockerComposeContainerScalingTest { private static final int REDIS_PORT = 6379; private Jedis[] clients = new Jedis[3]; - @BeforeClass + @BeforeAll public static void checkVersion() { - Assume.assumeTrue(TestEnvironment.dockerApiAtLeast("1.22")); + Assumptions.assumeThat(TestEnvironment.dockerApiAtLeast("1.22")).isTrue(); } - @Rule - public DockerComposeContainer environment = new DockerComposeContainer(new File("src/test/resources/scaled-compose-test.yml")) - .withScaledService("redis", 3) - .withExposedService("redis", REDIS_PORT) // implicit '_1' - .withExposedService("redis_2", REDIS_PORT) // explicit service index - .withExposedService("redis", 3, REDIS_PORT); // explicit service index via parameter - - @Before + @AutoClose + public DockerComposeContainer environment = new DockerComposeContainer( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/scaled-compose-test.yml") + ) + .withScaledService("redis", 3) + .withExposedService("redis", REDIS_PORT) // implicit '_1' + .withExposedService("redis_2", REDIS_PORT) // explicit service index + .withExposedService("redis", 3, REDIS_PORT); // explicit service index via parameter + + @BeforeEach public void setupClients() { + this.environment.start(); for (int i = 0; i < 3; i++) { - String name = String.format("redis_%d", i + 1); - clients[i] = new Jedis(environment.getServiceHost(name, REDIS_PORT), environment.getServicePort(name, REDIS_PORT)); + clients[i] = + new Jedis(environment.getServiceHost(name, REDIS_PORT), environment.getServicePort(name, REDIS_PORT)); } } @Test - public void simpleTest() { - + void simpleTest() { for (int i = 0; i < 3; i++) { clients[i].incr("somekey"); - assertEquals("Each redis instance is separate", "1", clients[i].get("somekey")); + assertThat(clients[i].get("somekey")).as("Each redis instance is separate").isEqualTo("1"); } } } diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeContainerTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerTest.java index 8d2addf7b5e..2bc855cacb2 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposeContainerTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerTest.java @@ -1,23 +1,108 @@ package org.testcontainers.junit; -import org.junit.Rule; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ContainerState; import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.DockerImageName; import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Collections; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; /** * Created by rnorth on 08/08/2015. */ -public class DockerComposeContainerTest extends BaseDockerComposeTest { +@Disabled +class DockerComposeContainerTest extends BaseDockerComposeTest { + + @AutoClose + public DockerComposeContainer environment = new DockerComposeContainer( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/compose-test.yml") + ) + .withExposedService("redis_1", REDIS_PORT) + .withExposedService("db_1", 3306); - @Rule - public DockerComposeContainer environment = new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) - .withExposedService("redis_1", REDIS_PORT) - .withExposedService("db_1", 3306) - ; + DockerComposeContainerTest() { + environment.start(); + } @Override protected DockerComposeContainer getEnvironment() { return environment; } + + @Test + void testGetServicePort() { + int serviceWithInstancePort = environment.getServicePort("redis_1", REDIS_PORT); + assertThat(serviceWithInstancePort).as("Port is set for service with instance number").isNotNull(); + int serviceWithoutInstancePort = environment.getServicePort("redis", REDIS_PORT); + assertThat(serviceWithoutInstancePort).as("Port is set for service with instance number").isNotNull(); + assertThat(serviceWithoutInstancePort).as("Service ports are the same").isEqualTo(serviceWithInstancePort); + } + + @Test + void shouldRetrieveContainerByServiceName() { + String existingServiceName = "db_1"; + Optional result = environment.getContainerByServiceName(existingServiceName); + + assertThat(result) + .as(String.format("Container should be found by service name %s", existingServiceName)) + .isPresent(); + assertThat(Collections.singletonList(3306)) + .as("Mapped port for result container was wrong, probably wrong container found") + .isEqualTo(result.get().getExposedPorts()); + } + + @Test + void shouldRetrieveContainerByServiceNameWithoutNumberedSuffix() { + String existingServiceName = "db"; + Optional result = environment.getContainerByServiceName(existingServiceName); + + assertThat(result) + .as(String.format("Container should be found by service name %s", existingServiceName)) + .isPresent(); + assertThat(result.get().getExposedPorts()) + .as("Mapped port for result container was wrong, perhaps wrong container was found") + .isEqualTo(Collections.singletonList(3306)); + } + + @Test + void shouldReturnEmptyResultOnNoneExistingService() { + String notExistingServiceName = "db_256"; + Optional result = environment.getContainerByServiceName(notExistingServiceName); + assertThat(result) + .as(String.format("No container should be found under service name %s", notExistingServiceName)) + .isNotPresent(); + } + + @Test + void shouldCreateContainerWhenFileNotPrefixedWithPath() throws IOException { + String validYaml = + "version: '2.2'\n" + + "services:\n" + + " http:\n" + + " build: .\n" + + " image: python:latest\n" + + " ports:\n" + + " - 8080:8080"; + + File filePathNotStartWithDotSlash = new File("docker-compose-test.yml"); + filePathNotStartWithDotSlash.createNewFile(); + filePathNotStartWithDotSlash.deleteOnExit(); + Files.write(filePathNotStartWithDotSlash.toPath(), validYaml.getBytes(StandardCharsets.UTF_8)); + + final DockerComposeContainer dockerComposeContainer = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + filePathNotStartWithDotSlash + ); + assertThat(dockerComposeContainer).as("Container could not be created using docker compose file").isNotNull(); + } } diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeContainerVolumeRemovalTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerVolumeRemovalTest.java new file mode 100644 index 00000000000..157b2ce046d --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerVolumeRemovalTest.java @@ -0,0 +1,84 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Disabled +class DockerComposeContainerVolumeRemovalTest { + + public static Object[][] params() { + return new Object[][] { { true, false }, { false, true } }; + } + + @ParameterizedTest + @MethodSource("params") + void performTest(boolean removeVolumes, boolean shouldVolumesBePresentAfterRunning) { + final File composeFile = new File("src/test/resources/compose-test.yml"); + + final AtomicReference volumeName = new AtomicReference<>(""); + try ( + DockerComposeContainer environment = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:1.29.2"), + composeFile + ) + .withExposedService("redis", 6379) + .withRemoveVolumes(removeVolumes) + .withRemoveImages(DockerComposeContainer.RemoveImages.ALL) + ) { + environment.start(); + + volumeName.set(volumeNameForRunningContainer("_redis_1")); + final boolean isVolumePresentWhileRunning = isVolumePresent(volumeName.get()); + assertThat(isVolumePresentWhileRunning).as("the container volume is present while running").isEqualTo(true); + } + + await() + .untilAsserted(() -> { + final boolean isVolumePresentAfterRunning = isVolumePresent(volumeName.get()); + assertThat(isVolumePresentAfterRunning) + .as("the container volume is present after running") + .isEqualTo(shouldVolumesBePresentAfterRunning); + }); + } + + private String volumeNameForRunningContainer(final String containerNameSuffix) { + return DockerClientFactory + .instance() + .client() + .listContainersCmd() + .exec() + .stream() + .filter(it -> Stream.of(it.getNames()).anyMatch(name -> name.endsWith(containerNameSuffix))) + .findFirst() + .map(container -> container.getMounts().get(0).getName()) + .orElseThrow(IllegalStateException::new); + } + + private boolean isVolumePresent(final String volumeName) { + Set nameFilter = new LinkedHashSet<>(1); + nameFilter.add(volumeName); + return DockerClientFactory + .instance() + .client() + .listVolumesCmd() + .withFilter("name", nameFilter) + .exec() + .getVolumes() + .stream() + .findFirst() + .isPresent(); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithBuildTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithBuildTest.java new file mode 100644 index 00000000000..f0cbe11cbb9 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithBuildTest.java @@ -0,0 +1,112 @@ +package org.testcontainers.junit; + +import com.github.dockerjava.api.model.Container; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.rnorth.ducttape.unreliables.Unreliables; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class DockerComposeContainerWithBuildTest { + + public static Stream params() { + return Stream.of( + Arguments.of(null, true, true), + Arguments.of(DockerComposeContainer.RemoveImages.LOCAL, false, true), + Arguments.of(DockerComposeContainer.RemoveImages.ALL, false, false) + ); + } + + @ParameterizedTest + @MethodSource("params") + void performTest( + DockerComposeContainer.RemoveImages removeMode, + boolean shouldBuiltImageBePresentAfterRunning, + boolean shouldPulledImageBePresentAfterRunning + ) { + final File composeFile = new File("src/test/resources/compose-build-test/docker-compose.yml"); + + final AtomicReference builtImageName = new AtomicReference<>(""); + final AtomicReference pulledImageName = new AtomicReference<>(""); + try ( + DockerComposeContainer environment = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:1.29.2"), + composeFile + ) + .withExposedService("customredis", 6379) + .withBuild(true) + .withRemoveImages(removeMode) + ) { + environment.start(); + + builtImageName.set(imageNameForRunningContainer("_customredis_1")); + final boolean isBuiltImagePresentWhileRunning = isImagePresent(builtImageName.get()); + assertThat(isBuiltImagePresentWhileRunning).as("the built image is present while running").isEqualTo(true); + + pulledImageName.set(imageNameForRunningContainer("_normalredis_1")); + final boolean isPulledImagePresentWhileRunning = isImagePresent(pulledImageName.get()); + assertThat(isPulledImagePresentWhileRunning) + .as("the pulled image is present while running") + .isEqualTo(true); + } + + Unreliables.retryUntilSuccess( + 10, + TimeUnit.SECONDS, + () -> { + final boolean isBuiltImagePresentAfterRunning = isImagePresent(builtImageName.get()); + assertThat(isBuiltImagePresentAfterRunning) + .as("the built image is not present after running") + .isEqualTo(shouldBuiltImageBePresentAfterRunning); + return null; + } + ); + + Unreliables.retryUntilSuccess( + 10, + TimeUnit.SECONDS, + () -> { + final boolean isPulledImagePresentAfterRunning = isImagePresent(pulledImageName.get()); + assertThat(isPulledImagePresentAfterRunning) + .as("the pulled image is present after running") + .isEqualTo(shouldPulledImageBePresentAfterRunning); + return null; + } + ); + } + + private String imageNameForRunningContainer(final String containerNameSuffix) { + return DockerClientFactory + .instance() + .client() + .listContainersCmd() + .exec() + .stream() + .filter(it -> Stream.of(it.getNames()).anyMatch(name -> name.endsWith(containerNameSuffix))) + .findFirst() + .map(Container::getImage) + .orElseThrow(IllegalStateException::new); + } + + private boolean isImagePresent(final String imageName) { + return DockerClientFactory + .instance() + .client() + .listImagesCmd() + .withFilter("reference", Collections.singletonList(imageName)) + .exec() + .stream() + .findFirst() + .isPresent(); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithCopyFilesTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithCopyFilesTest.java new file mode 100644 index 00000000000..c543678faab --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithCopyFilesTest.java @@ -0,0 +1,74 @@ +package org.testcontainers.junit; + +import io.restassured.RestAssured; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +class DockerComposeContainerWithCopyFilesTest { + + @Test + void testShouldCopyAllFilesByDefault() throws IOException { + try ( + DockerComposeContainer environment = new DockerComposeContainer( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/compose-file-copy-inclusions/compose.yml") + ) + .withExposedService("app", 8080) + ) { + environment.start(); + + String response = readStringFromURL(environment); + assertThat(response).isEqualTo("MY_ENV_VARIABLE: override"); + } + } + + @Test + void testWithFileCopyInclusionUsingFilePath() throws IOException { + try ( + DockerComposeContainer environment = new DockerComposeContainer( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/compose-file-copy-inclusions/compose-root-only.yml") + ) + .withExposedService("app", 8080) + .withCopyFilesInContainer("Dockerfile", "EnvVariableRestEndpoint.java", ".env") + ) { + environment.start(); + + String response = readStringFromURL(environment); + + // The `test/.env` file is not copied, now so we get the original value + assertThat(response).isEqualTo("MY_ENV_VARIABLE: original"); + } + } + + @Test + void testWithFileCopyInclusionUsingDirectoryPath() throws IOException { + try ( + DockerComposeContainer environment = new DockerComposeContainer( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/compose-file-copy-inclusions/compose-test-only.yml") + ) + .withExposedService("app", 8080) + .withCopyFilesInContainer("Dockerfile", "EnvVariableRestEndpoint.java", "test") + ) { + environment.start(); + + String response = readStringFromURL(environment); + // The test directory (with its contents) is copied, so we get the override + assertThat(response).isEqualTo("MY_ENV_VARIABLE: override"); + } + } + + private static String readStringFromURL(DockerComposeContainer container) throws IOException { + Integer servicePort = container.getServicePort("app_1", 8080); + String serviceHost = container.getServiceHost("app_1", 8080); + String requestURL = "http://" + serviceHost + ":" + servicePort + "/env"; + return RestAssured.get(requestURL).thenReturn().body().asString(); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithOptionsTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithOptionsTest.java new file mode 100644 index 00000000000..84508326522 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithOptionsTest.java @@ -0,0 +1,85 @@ +package org.testcontainers.junit; + +import com.google.common.collect.ImmutableSet; +import org.assertj.core.api.Assumptions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.CommandLine; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.util.Set; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests the options associated with the docker-compose command. + */ +class DockerComposeContainerWithOptionsTest { + + public static Stream params() { + return Stream.of( + // Test the happy day case. The compatibility option should be accepted by docker-compose. + Arguments.of( + new File("src/test/resources/compose-options-test/with-deploy-block.yml"), + false, + ImmutableSet.of("--compatibility"), + false + ), + // Test with flags absent. Docker compose will warn but continue, ignoring the deploy block. + Arguments.of( + new File("src/test/resources/compose-options-test/with-deploy-block.yml"), + false, + ImmutableSet.of(""), + false + ), + // Test with a bad option. Compose will complain. + Arguments.of( + new File("src/test/resources/compose-options-test/with-deploy-block.yml"), + false, + ImmutableSet.of("--bad-option"), + true + ), + // Local compose + Arguments.of( + new File("src/test/resources/compose-options-test/with-deploy-block.yml"), + true, + ImmutableSet.of("--compatibility"), + false + ) + ); + } + + @ParameterizedTest(name = "docker-compose test [compose file: {0}, local: {1}, options: {2}, expected result: {3}]") + @MethodSource("params") + void performTest(File composeFile, boolean localMode, Set options, boolean expectError) { + DockerComposeContainer environment; + if (localMode) { + Assumptions + .assumeThat(CommandLine.executableExists(DockerComposeContainer.COMPOSE_EXECUTABLE)) + .as("docker-compose executable exists") + .isTrue(); + Assumptions + .assumeThat(CommandLine.runShellCommand("docker-compose", "--version")) + .doesNotStartWith("Docker Compose version v2"); + + environment = + new DockerComposeContainer<>(composeFile).withOptions(options.stream().toArray(String[]::new)); + } else { + environment = + new DockerComposeContainer<>(DockerImageName.parse("docker/compose:debian-1.29.2"), composeFile) + .withOptions(options.stream().toArray(String[]::new)); + } + + try { + environment.start(); + assertThat(expectError).isEqualTo(false); + environment.stop(); + } catch (Exception e) { + assertThat(expectError).isEqualTo(true); + } + } +} diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeDoNotOverrideTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeDoNotOverrideTest.java deleted file mode 100644 index 0037f48b6d6..00000000000 --- a/core/src/test/java/org/testcontainers/junit/DockerComposeDoNotOverrideTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package org.testcontainers.junit; - -import com.google.common.util.concurrent.Uninterruptibles; -import org.junit.Assume; -import org.junit.BeforeClass; -import org.junit.Rule; -import org.junit.Test; -import org.rnorth.ducttape.unreliables.Unreliables; -import org.testcontainers.containers.DockerComposeContainer; -import org.testcontainers.utility.TestEnvironment; - -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.Socket; -import java.util.concurrent.TimeUnit; - -import static org.rnorth.visibleassertions.VisibleAssertions.info; -import static org.rnorth.visibleassertions.VisibleAssertions.pass; - -/** - * Created by rnorth on 11/06/2016. - */ -public class DockerComposeDoNotOverrideTest { - - static final String DOCKER_COMPOSE_OVERRIDE_TEST_BASE_YML = "src/test/resources/docker-compose-base.yml"; - static final String DOCKER_COMPOSE_OVERRIDE_TEST_BASE_ENV = "bar=base"; - - static final String DOCKER_COMPOSE_OVERRIDE_TEST_OVERRIDE_YML = "src/test/resources/docker-compose-non-default-override.yml"; - static final String DOCKER_COMPOSE_OVERRIDE_TEST_OVERRIDE_ENV = "bar=overwritten"; - - @Rule - public DockerComposeContainer compose = - new DockerComposeContainer(new File(DOCKER_COMPOSE_OVERRIDE_TEST_BASE_YML)) - .withExposedService("alpine_1", 3000); - - @BeforeClass - public static void checkVersion() { - Assume.assumeTrue(TestEnvironment.dockerApiAtLeast("1.22")); - } - - - @Test(timeout = 30_000) - public void testEnvVar() throws IOException { - BufferedReader br = Unreliables.retryUntilSuccess(10, TimeUnit.SECONDS, () -> { - Uninterruptibles.sleepUninterruptibly(1, TimeUnit.SECONDS); - - Socket socket = new Socket(compose.getServiceHost("alpine_1", 3000), compose.getServicePort("alpine_1", 3000)); - return new BufferedReader(new InputStreamReader(socket.getInputStream())); - }); - - Unreliables.retryUntilTrue(10, TimeUnit.SECONDS, () -> { - while (br.ready()) { - String line = br.readLine(); - if (line.contains(DOCKER_COMPOSE_OVERRIDE_TEST_BASE_ENV)) { - pass("Mapped environment variable was found"); - return true; - } - } - info("Mapped environment variable was not found yet - process probably not ready"); - Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS); - return false; - }); - - } -} diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeErrorHandlingTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeErrorHandlingTest.java index c48cff1ff14..907011329ec 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposeErrorHandlingTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeErrorHandlingTest.java @@ -1,29 +1,28 @@ package org.testcontainers.junit; -import org.junit.Test; -import org.junit.runner.Description; -import org.rnorth.visibleassertions.VisibleAssertions; -import org.testcontainers.containers.ContainerLaunchException; +import org.junit.jupiter.api.Test; import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.DockerImageName; import java.io.File; -/** - * Created by rnorth on 08/08/2015. - */ -public class DockerComposeErrorHandlingTest { +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +class DockerComposeErrorHandlingTest { @Test - public void simpleTest() { - - DockerComposeContainer environment = new DockerComposeContainer(new File("src/test/resources/invalid-compose.yml")) + void simpleTest() { + assertThat( + catchThrowable(() -> { + DockerComposeContainer environment = new DockerComposeContainer( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/invalid-compose.yml") + ) .withExposedService("something", 123); - - VisibleAssertions.assertThrows("starting with an invalid docker-compose file throws an exception", - ContainerLaunchException.class, - () -> { - environment.starting(Description.createTestDescription(Object.class, "name")); - }); + }) + ) + .as("starting with an invalid docker-compose file throws an exception") + .isInstanceOf(IllegalArgumentException.class); } } diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeLocalImageTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeLocalImageTest.java new file mode 100644 index 00000000000..1853a2d4ff1 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeLocalImageTest.java @@ -0,0 +1,31 @@ +package org.testcontainers.junit; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.core.command.PullImageResultCallback; +import org.junit.jupiter.api.Test; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; + +class DockerComposeLocalImageTest { + + @Test + void usesLocalImageEvenWhenPullFails() throws InterruptedException { + tagImage("redis:6-alpine", "redis-local", "latest"); + + DockerComposeContainer composeContainer = new DockerComposeContainer( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/local-compose-test.yml") + ) + .withExposedService("redis", 6379); + composeContainer.start(); + } + + private void tagImage(String sourceImage, String targetImage, String targetTag) throws InterruptedException { + DockerClient client = DockerClientFactory.instance().client(); + client.pullImageCmd(sourceImage).exec(new PullImageResultCallback()).awaitCompletion(); + client.tagImageCmd(sourceImage, targetImage, targetTag).exec(); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeLogConsumerTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeLogConsumerTest.java new file mode 100644 index 00000000000..7f63bdce9fd --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeLogConsumerTest.java @@ -0,0 +1,41 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.output.OutputFrame.OutputType; +import org.testcontainers.containers.output.WaitingConsumer; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +class DockerComposeLogConsumerTest { + + @Test + void testLogConsumer() throws TimeoutException { + WaitingConsumer logConsumer = new WaitingConsumer(); + DockerComposeContainer environment = new DockerComposeContainer( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/v2-compose-test.yml") + ) + .withExposedService("redis_1", 6379) + .withLogConsumer("redis_1", logConsumer); + + try { + environment.start(); + logConsumer.waitUntil( + frame -> { + return ( + frame.getType() == OutputType.STDOUT && + frame.getUtf8String().contains("Ready to accept connections") + ); + }, + 5, + TimeUnit.SECONDS + ); + } finally { + environment.stop(); + } + } +} diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeOverrideTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeOverrideTest.java deleted file mode 100644 index 8d72d8d2533..00000000000 --- a/core/src/test/java/org/testcontainers/junit/DockerComposeOverrideTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.testcontainers.junit; - -import com.google.common.util.concurrent.Uninterruptibles; -import org.junit.Assume; -import org.junit.BeforeClass; -import org.junit.Rule; -import org.junit.Test; -import org.rnorth.ducttape.unreliables.Unreliables; -import org.testcontainers.containers.DockerComposeContainer; -import org.testcontainers.utility.TestEnvironment; - -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.Socket; -import java.util.concurrent.TimeUnit; - -import static org.rnorth.visibleassertions.VisibleAssertions.info; -import static org.rnorth.visibleassertions.VisibleAssertions.pass; -import static org.testcontainers.junit.DockerComposeDoNotOverrideTest.*; - -/** - * Created by rnorth on 11/06/2016. - */ -public class DockerComposeOverrideTest { - - @Rule - public DockerComposeContainer compose = - new DockerComposeContainer( - new File(DOCKER_COMPOSE_OVERRIDE_TEST_BASE_YML), - new File(DOCKER_COMPOSE_OVERRIDE_TEST_OVERRIDE_YML)) - .withExposedService("alpine_1", 3000); - - @BeforeClass - public static void checkVersion() { - Assume.assumeTrue(TestEnvironment.dockerApiAtLeast("1.22")); - } - - - @Test(timeout = 30_000) - public void testEnvVar() throws IOException { - BufferedReader br = Unreliables.retryUntilSuccess(10, TimeUnit.SECONDS, () -> { - Uninterruptibles.sleepUninterruptibly(1, TimeUnit.SECONDS); - - Socket socket = new Socket(compose.getServiceHost("alpine_1", 3000), compose.getServicePort("alpine_1", 3000)); - return new BufferedReader(new InputStreamReader(socket.getInputStream())); - }); - - Unreliables.retryUntilTrue(10, TimeUnit.SECONDS, () -> { - while (br.ready()) { - String line = br.readLine(); - if (line.contains(DOCKER_COMPOSE_OVERRIDE_TEST_OVERRIDE_ENV)) { - pass("Mapped environment variable was found"); - return true; - } - } - info("Mapped environment variable was not found yet - process probably not ready"); - Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS); - return false; - }); - - } -} diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposePassthroughTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposePassthroughTest.java index 48c5b0db15f..a4f2707c583 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposePassthroughTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposePassthroughTest.java @@ -1,61 +1,62 @@ package org.testcontainers.junit; -import com.google.common.util.concurrent.Uninterruptibles; -import org.junit.Assume; -import org.junit.BeforeClass; -import org.junit.Rule; -import org.junit.Test; -import org.rnorth.ducttape.unreliables.Unreliables; +import org.assertj.core.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ContainerState; import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy; +import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.TestEnvironment; -import java.io.BufferedReader; import java.io.File; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.Socket; -import java.util.concurrent.TimeUnit; +import java.util.Arrays; +import java.util.Objects; -import static org.rnorth.visibleassertions.VisibleAssertions.info; -import static org.rnorth.visibleassertions.VisibleAssertions.pass; +import static org.assertj.core.api.Assertions.assertThat; /** * Created by rnorth on 11/06/2016. */ -public class DockerComposePassthroughTest { - - @BeforeClass - public static void checkVersion() { - Assume.assumeTrue(TestEnvironment.dockerApiAtLeast("1.22")); +class DockerComposePassthroughTest { + + @Test + void testContainerInstanceProperties() { + Assumptions.assumeThat(TestEnvironment.dockerApiAtLeast("1.22")).isTrue(); + + TestWaitStrategy waitStrategy = new TestWaitStrategy(); + try ( + DockerComposeContainer compose = new DockerComposeContainer( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/v2-compose-test-passthrough.yml") + ) + .withEnv("foo", "bar") + .withExposedService("alpine_1", 3000, waitStrategy) + ) { + compose.start(); + + final ContainerState container = waitStrategy.getContainer(); + + //check environment variable was set + assertThat(Arrays.asList(Objects.requireNonNull(container.getContainerInfo().getConfig().getEnv()))) + .as("Environment variable set correctly") + .containsOnlyOnce("bar=bar"); + + //check other container properties + assertThat(container.getContainerId()).as("Container id is not null").isNotNull(); + assertThat(container.getMappedPort(3000)).as("Port mapped").isNotNull(); + assertThat(container.getExposedPorts()).containsExactly(3000); + } } - @Rule - public DockerComposeContainer compose = - new DockerComposeContainer(new File("src/test/resources/v2-compose-test-passthrough.yml")) - .withEnv("foo", "bar") - .withExposedService("alpine_1", 3000); - - @Test(timeout = 30_000) - public void testEnvVar() throws IOException { - BufferedReader br = Unreliables.retryUntilSuccess(10, TimeUnit.SECONDS, () -> { - Uninterruptibles.sleepUninterruptibly(1, TimeUnit.SECONDS); - - Socket socket = new Socket(compose.getServiceHost("alpine_1", 3000), compose.getServicePort("alpine_1", 3000)); - return new BufferedReader(new InputStreamReader(socket.getInputStream())); - }); - - Unreliables.retryUntilTrue(10, TimeUnit.SECONDS, () -> { - while (br.ready()) { - String line = br.readLine(); - if (line.contains("bar=bar")) { - pass("Mapped environment variable was found"); - return true; - } - } - info("Mapped environment variable was not found yet - process probably not ready"); - Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS); - return false; - }); + /* + * WaitStrategy is the only class that has access to the DockerComposeServiceInstance reference + * Using a custom WaitStrategy to expose the reference for testability + */ + class TestWaitStrategy extends HostPortWaitStrategy { + @SuppressWarnings("unchecked") + public ContainerState getContainer() { + return this.waitStrategyTarget; + } } } diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeServiceTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeServiceTest.java new file mode 100644 index 00000000000..c1c295ef238 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeServiceTest.java @@ -0,0 +1,46 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Disabled +class DockerComposeServiceTest extends BaseDockerComposeTest { + + @AutoClose + public DockerComposeContainer environment = new DockerComposeContainer( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/compose-test.yml") + ) + .withServices("redis") + .withExposedService("redis_1", REDIS_PORT); + + DockerComposeServiceTest() { + environment.start(); + } + + @Override + protected DockerComposeContainer getEnvironment() { + return environment; + } + + @Test + void testDbIsNotStarting() { + assertThatThrownBy(() -> { + environment.getServicePort("db_1", 10001); + }) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testRedisIsStarting() { + assertThat(environment.getServicePort("redis_1", REDIS_PORT)).as("Redis server started").isNotNull(); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeV2FormatTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeV2FormatTest.java index bed19bd0d09..8a8f8f0da0b 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposeV2FormatTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeV2FormatTest.java @@ -1,18 +1,28 @@ package org.testcontainers.junit; -import org.junit.Rule; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.Disabled; import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.DockerImageName; import java.io.File; /** * Created by rnorth on 21/05/2016. */ -public class DockerComposeV2FormatTest extends BaseDockerComposeTest { +@Disabled +class DockerComposeV2FormatTest extends BaseDockerComposeTest { - @Rule - public DockerComposeContainer environment = new DockerComposeContainer(new File("src/test/resources/v2-compose-test.yml")) - .withExposedService("redis_1", REDIS_PORT); + @AutoClose + public DockerComposeContainer environment = new DockerComposeContainer( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/v2-compose-test.yml") + ) + .withExposedService("redis_1", REDIS_PORT); + + DockerComposeV2FormatTest() { + environment.start(); + } @Override protected DockerComposeContainer getEnvironment() { diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeV2FormatWithIdentifierTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeV2FormatWithIdentifierTest.java new file mode 100644 index 00000000000..dd198181d41 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeV2FormatWithIdentifierTest.java @@ -0,0 +1,29 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.Disabled; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; + +@Disabled +class DockerComposeV2FormatWithIdentifierTest extends BaseDockerComposeTest { + + @AutoClose + public DockerComposeContainer environment = new DockerComposeContainer( + DockerImageName.parse("docker/compose:1.29.2"), + "TEST", + new File("src/test/resources/v2-compose-test.yml") + ) + .withExposedService("redis_1", REDIS_PORT); + + DockerComposeV2FormatWithIdentifierTest() { + environment.start(); + } + + @Override + protected DockerComposeContainer getEnvironment() { + return this.environment; + } +} diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeV2WithNetworkTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeV2WithNetworkTest.java index 26764476687..2cf506059c5 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposeV2WithNetworkTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeV2WithNetworkTest.java @@ -1,21 +1,28 @@ package org.testcontainers.junit; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.Disabled; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.DockerImageName; + import java.io.File; -import org.junit.Rule; -import org.testcontainers.containers.DockerComposeContainer; +@Disabled +class DockerComposeV2WithNetworkTest extends BaseDockerComposeTest { -public class DockerComposeV2WithNetworkTest extends BaseDockerComposeTest { + @AutoClose + public DockerComposeContainer environment = new DockerComposeContainer( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/v2-compose-test-with-network.yml") + ) + .withExposedService("redis_1", REDIS_PORT); - @Rule - public DockerComposeContainer environment = new DockerComposeContainer(new File("src/test/resources/v2-compose-test-with-network.yml")) - .withExposedService("redis_1", REDIS_PORT); + DockerComposeV2WithNetworkTest() { + environment.start(); + } @Override protected DockerComposeContainer getEnvironment() { return environment; } - - - } diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeWaitStrategyTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeWaitStrategyTest.java new file mode 100644 index 00000000000..6f56ef9c24a --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeWaitStrategyTest.java @@ -0,0 +1,114 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.assertj.core.api.Assertions.fail; + +class DockerComposeWaitStrategyTest { + + private static final int REDIS_PORT = 6379; + + private DockerComposeContainer environment; + + @BeforeEach + public final void setUp() { + environment = + new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/compose-test.yml") + ); + } + + @AfterEach + public final void cleanUp() { + environment.stop(); + } + + @Test + void testWaitOnListeningPort() { + environment.withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort()); + + try { + environment.start(); + } catch (RuntimeException e) { + fail("Docker compose should start after waiting for listening port with failed with: " + e); + } + } + + @Test + void testWaitOnMultipleStrategiesPassing() { + environment + .withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort()) + .withExposedService("db_1", 3306, Wait.forLogMessage(".*ready for connections.*\\s", 1)) + .withTailChildContainers(true); + + try { + environment.start(); + } catch (RuntimeException e) { + fail("Docker compose should start after waiting for listening port with failed with: " + e); + } + } + + @Test + void testWaitingFails() { + environment.withExposedService( + "redis_1", + REDIS_PORT, + Wait.forHttp("/test").withStartupTimeout(Duration.ofSeconds(10)) + ); + assertThat(catchThrowable(() -> environment.start())) + .as("waiting on an invalid http path times out") + .isInstanceOf(RuntimeException.class); + } + + @Test + void testWaitOnOneOfMultipleStrategiesFailing() { + environment + .withExposedService( + "redis_1", + REDIS_PORT, + Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(10)) + ) + .waitingFor( + "db_1", + Wait.forLogMessage(".*test test test.*\\s", 1).withStartupTimeout(Duration.ofSeconds(10)) + ) + .withTailChildContainers(true); + + assertThat(catchThrowable(() -> environment.start())) + .as("waiting on one failing strategy to time out") + .isInstanceOf(RuntimeException.class); + } + + @Test + void testWaitingForNonexistentServices() { + String existentServiceName = "db_1"; + String nonexistentServiceName1 = "some_nonexistent_service_1"; + String nonexistentServiceName2 = "some_nonexistent_service_2"; + WaitStrategy someWaitStrategy = Mockito.mock(WaitStrategy.class); + + environment + .waitingFor(existentServiceName, someWaitStrategy) + .waitingFor(nonexistentServiceName1, someWaitStrategy) + .waitingFor(nonexistentServiceName2, someWaitStrategy); + + Throwable thrownWhenRequestedToWaitForNonexistentService = catchThrowable(environment::start); + + assertThat(thrownWhenRequestedToWaitForNonexistentService) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(nonexistentServiceName1, nonexistentServiceName2) + .hasMessageNotContaining(existentServiceName); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/DockerNetworkModeTest.java b/core/src/test/java/org/testcontainers/junit/DockerNetworkModeTest.java index 24215094e08..c35809f74e9 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerNetworkModeTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerNetworkModeTest.java @@ -1,73 +1,50 @@ package org.testcontainers.junit; -import org.junit.*; +import com.github.dockerjava.api.model.NetworkSettings; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.testcontainers.TestImages; import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.output.OutputFrame; -import org.testcontainers.containers.output.ToStringConsumer; -import org.testcontainers.containers.output.WaitingConsumer; -import org.testcontainers.utility.TestEnvironment; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.function.Consumer; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; /** * Simple tests of named network modes - more may be possible, but may not be reproducible * without other setup steps. */ -public class DockerNetworkModeTest { - - @BeforeClass - public static void checkVersion() { - Assume.assumeTrue(TestEnvironment.dockerApiAtLeast("1.22")); - } - - @ClassRule - public static GenericContainer noNetwork = new GenericContainer("alpine:3.2") - .withNetworkMode("none") - .withCommand("ping -c 5 www.google.com"); - - @ClassRule - public static GenericContainer hostNetwork = new GenericContainer("alpine:3.2") - .withNetworkMode("host") - .withCommand("ping -c 5 www.google.com"); - - @ClassRule - public static GenericContainer bridgedNetwork = new GenericContainer("alpine:3.2") - .withNetworkMode("bridge") - .withCommand("ping -c 5 www.google.com"); - - @Test - public void testNoNetworkContainer() throws TimeoutException { - String output = getContainerOutput(noNetwork); - - assertTrue("'none' network causes a network access error", output.contains("bad address")); - } +@Slf4j +class DockerNetworkModeTest { @Test - public void testHostNetworkContainer() throws TimeoutException { - String output = getContainerOutput(hostNetwork); - - assertTrue("'host' network can access the internet", output.contains("seq=1")); + void testNoNetworkContainer() { + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + .withCommand("true") + .withNetworkMode("none") + ) { + container.start(); + NetworkSettings networkSettings = container.getContainerInfo().getNetworkSettings(); + + assertThat(networkSettings.getNetworks()).as("only one network is set").hasSize(1); + assertThat(networkSettings.getNetworks()).as("network is 'none'").containsKey("none"); + } } @Test - public void testBridgedNetworkContainer() throws TimeoutException { - String output = getContainerOutput(bridgedNetwork); - - assertTrue("'bridge' network can access the internet", output.contains("seq=1")); - } - - private String getContainerOutput(GenericContainer container) throws TimeoutException { - WaitingConsumer waitingConsumer = new WaitingConsumer(); - ToStringConsumer toStringConsumer = new ToStringConsumer(); - Consumer composedConsumer = waitingConsumer.andThen(toStringConsumer); - - container.followOutput(composedConsumer); - waitingConsumer.waitUntilEnd(10, TimeUnit.SECONDS); - - return toStringConsumer.toUtf8String(); + void testHostNetworkContainer() { + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + .withCommand("true") + .withNetworkMode("host") + ) { + container.start(); + NetworkSettings networkSettings = container.getContainerInfo().getNetworkSettings(); + + assertThat(networkSettings.getNetworks()).as("only one network is set").hasSize(1); + assertThat(networkSettings.getNetworks()).as("network is 'host'").containsKey("host"); + } } } diff --git a/core/src/test/java/org/testcontainers/junit/DockerfileContainerTest.java b/core/src/test/java/org/testcontainers/junit/DockerfileContainerTest.java index 8f601b2e737..61500ca7993 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerfileContainerTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerfileContainerTest.java @@ -4,44 +4,51 @@ import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; -import org.junit.Rule; -import org.junit.Test; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.Test; import org.testcontainers.containers.GenericContainer; import org.testcontainers.images.builder.ImageFromDockerfile; import java.io.IOException; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; /** * Simple test case / demonstration of creating a fresh container image from a Dockerfile DSL */ -public class DockerfileContainerTest { +class DockerfileContainerTest { - @Rule + @AutoClose public GenericContainer dslContainer = new GenericContainer( - new ImageFromDockerfile("tcdockerfile/nginx", false).withDockerfileFromBuilder(builder -> { - builder - .from("alpine:3.2") - .run("apk add --update nginx") - .cmd("nginx", "-g", "daemon off;") - .build(); })) - .withExposedPorts(80); + new ImageFromDockerfile("tcdockerfile/nginx", false) + .withDockerfileFromBuilder(builder -> { + builder + .from("alpine:3.2") // + .run("apk add --update nginx") + .cmd("nginx", "-g", "daemon off;") + .build(); + }) + ) + .withExposedPorts(80); @Test - public void simpleDslTest() throws IOException { - String address = String.format("http://%s:%s", dslContainer.getContainerIpAddress(), dslContainer.getMappedPort(80)); + void simpleDslTest() throws IOException { + dslContainer.start(); + + String address = String.format("http://%s:%s", dslContainer.getHost(), dslContainer.getMappedPort(80)); CloseableHttpClient httpClient = HttpClientBuilder.create().build(); HttpGet get = new HttpGet(address); try (CloseableHttpResponse response = httpClient.execute(get)) { - assertEquals("A container built from a dockerfile can run nginx as expected, and returns a good status code", - 200, - response.getStatusLine().getStatusCode()); - assertTrue("A container built from a dockerfile can run nginx as expected, and returns an expected Server header", - response.getHeaders("Server")[0].getValue().contains("nginx")); + assertThat(response.getStatusLine().getStatusCode()) + .as("A container built from a dockerfile can run nginx as expected, and returns a good status code") + .isEqualTo(200); + assertThat(response.getHeaders("Server")[0].getValue()) + .as( + "A container built from a dockerfile can run nginx as expected, and returns an expected Server header" + ) + .contains("nginx"); } } } diff --git a/core/src/test/java/org/testcontainers/junit/DockerfileTest.java b/core/src/test/java/org/testcontainers/junit/DockerfileTest.java index a2b11e5bc9d..9f85af8d209 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerfileTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerfileTest.java @@ -1,49 +1,46 @@ package org.testcontainers.junit; import com.github.dockerjava.api.command.BuildImageCmd; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.OutputFrame; import org.testcontainers.containers.output.WaitingConsumer; import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; import org.testcontainers.images.builder.ImageFromDockerfile; import org.testcontainers.images.builder.Transferable; -import java.io.OutputStream; import java.util.Arrays; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import static org.rnorth.visibleassertions.VisibleAssertions.pass; -import static org.testcontainers.containers.output.OutputFrame.OutputType.STDOUT; - -public class DockerfileTest { +class DockerfileTest { private static final Logger LOGGER = LoggerFactory.getLogger(DockerfileTest.class); @Test - public void simpleDockerfileWorks() { + void simpleDockerfileWorks() { ImageFromDockerfile image = new ImageFromDockerfile() - .withFileFromString("folder/someFile.txt", "hello") - .withFileFromClasspath("test.txt", "mappable-resource/test-resource.txt") - .withFileFromClasspath("Dockerfile", "mappable-dockerfile/Dockerfile"); + .withFileFromString("folder/someFile.txt", "hello") + .withFileFromClasspath("test.txt", "mappable-resource/test-resource.txt") + .withFileFromClasspath("Dockerfile", "mappable-dockerfile/Dockerfile"); verifyImage(image); } @Test - public void customizableImage() { + void customizableImage() { ImageFromDockerfile image = new ImageFromDockerfile() { @Override protected void configure(BuildImageCmd buildImageCmd) { super.configure(buildImageCmd); List dockerfile = Arrays.asList( - "FROM alpine:3.2", - "RUN echo 'hello from Docker build process'", - "CMD yes" + "FROM alpine:3.17", + "RUN echo 'hello from Docker build process'", + "CMD yes" ); withFileFromString("Dockerfile", String.join("\n", dockerfile)); @@ -55,59 +52,72 @@ protected void configure(BuildImageCmd buildImageCmd) { } @Test - public void dockerfileBuilderWorks() { + void dockerfileBuilderWorks() { ImageFromDockerfile image = new ImageFromDockerfile() - .withFileFromClasspath("test.txt", "mappable-resource/test-resource.txt") - .withFileFromString("folder/someFile.txt", "hello") - .withDockerfileFromBuilder(builder -> builder - .from("alpine:3.2") - .workDir("/app") - .add("test.txt", "test file.txt") - .run("ls", "-la", "/app/test file.txt") - .copy("folder/someFile.txt", "/someFile.txt") - .expose(80, 8080) - .cmd("while true; do cat /someFile.txt | nc -l -p 80; done") - ); + .withFileFromClasspath("test.txt", "mappable-resource/test-resource.txt") + .withFileFromString("folder/someFile.txt", "hello") + .withDockerfileFromBuilder(builder -> { + builder + .from("alpine:3.17") + .workDir("/app") + .add("test.txt", "test file.txt") + .run("ls", "-la", "/app/test file.txt") + .copy("folder/someFile.txt", "/someFile.txt") + .expose(80, 8080) + .cmd("while true; do cat /someFile.txt | nc -l -p 80; done"); + }); verifyImage(image); } @Test - public void filePermissions() throws TimeoutException { - + void filePermissions() throws TimeoutException { WaitingConsumer consumer = new WaitingConsumer(); ImageFromDockerfile image = new ImageFromDockerfile() - .withFileFromTransferable("/someFile.txt", new Transferable() { + .withFileFromTransferable( + "/someFile.txt", + new Transferable() { @Override public long getSize() { return 0; } @Override - public int getFileMode() { - return 0123; + public byte[] getBytes() { + return new byte[0]; } @Override - public void transferTo(OutputStream outputStream) { + public String getDescription() { + return "test file"; + } + @Override + public int getFileMode() { + return 0123; } - }) - .withDockerfileFromBuilder(builder -> builder - .from("alpine:3.2") - .copy("someFile.txt", "/someFile.txt") - .cmd("stat -c \"%a\" /someFile.txt") - ); + } + ) + .withDockerfileFromBuilder(builder -> { + builder + .from("alpine:3.17") // + .copy("someFile.txt", "/someFile.txt") + .cmd("stat -c \"%a\" /someFile.txt"); + }); GenericContainer container = new GenericContainer(image) - .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) - .withLogConsumer(consumer); + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + .withLogConsumer(consumer); try { container.start(); - consumer.waitUntil(frame -> frame.getType() == STDOUT && frame.getUtf8String().contains("123"), 5, TimeUnit.SECONDS); + consumer.waitUntil( + frame -> frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().contains("123"), + 5, + TimeUnit.SECONDS + ); } finally { container.stop(); } @@ -118,8 +128,6 @@ protected void verifyImage(ImageFromDockerfile image) { try { container.start(); - - pass("Should start from Dockerfile"); } finally { container.stop(); } diff --git a/core/src/test/java/org/testcontainers/junit/ExecInContainerTest.java b/core/src/test/java/org/testcontainers/junit/ExecInContainerTest.java new file mode 100644 index 00000000000..365229e5909 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/ExecInContainerTest.java @@ -0,0 +1,77 @@ +package org.testcontainers.junit; + +import org.assertj.core.api.Assumptions; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.Test; +import org.testcontainers.TestImages; +import org.testcontainers.containers.ExecConfig; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.TestEnvironment; + +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExecInContainerTest { + + @AutoClose + public static GenericContainer redis = new GenericContainer<>(TestImages.REDIS_IMAGE).withExposedPorts(6379); + + static { + redis.start(); + } + + @Test + void shouldExecuteCommand() throws Exception { + // The older "lxc" execution driver doesn't support "exec". At the time of writing (2016/03/29), + // that's the case for CircleCI. + // Once they resolve the issue, this clause can be removed. + Assumptions.assumeThat(TestEnvironment.dockerExecutionDriverSupportsExec()).isTrue(); + + final GenericContainer.ExecResult result = redis.execInContainer("redis-cli", "role"); + assertThat(result.getStdout()) + .as("Output for \"redis-cli role\" command should start with \"master\"") + .startsWith("master"); + assertThat(result.getStderr()).as("Stderr for \"redis-cli role\" command should be empty").isEmpty(); + // We expect to reach this point for modern Docker versions. + } + + @Test + void shouldExecuteCommandWithUser() throws Exception { + // The older "lxc" execution driver doesn't support "exec". At the time of writing (2016/03/29), + // that's the case for CircleCI. + // Once they resolve the issue, this clause can be removed. + Assumptions.assumeThat(TestEnvironment.dockerExecutionDriverSupportsExec()).isTrue(); + + final GenericContainer.ExecResult result = redis.execInContainerWithUser("redis", "whoami"); + assertThat(result.getStdout()) + .as("Output for \"whoami\" command should start with \"redis\"") + .startsWith("redis"); + assertThat(result.getStderr()).as("Stderr for \"whoami\" command should be empty").isEmpty(); + // We expect to reach this point for modern Docker versions. + } + + @Test + void shouldExecuteCommandWithWorkdir() throws Exception { + Assumptions.assumeThat(TestEnvironment.dockerExecutionDriverSupportsExec()).isTrue(); + + final GenericContainer.ExecResult result = redis.execInContainer( + ExecConfig.builder().workDir("/opt").command(new String[] { "pwd" }).build() + ); + assertThat(result.getStdout()).startsWith("/opt"); + } + + @Test + void shouldExecuteCommandWithEnvVars() throws Exception { + Assumptions.assumeThat(TestEnvironment.dockerExecutionDriverSupportsExec()).isTrue(); + + final GenericContainer.ExecResult result = redis.execInContainer( + ExecConfig + .builder() + .envVars(Collections.singletonMap("TESTCONTAINERS", "JAVA")) + .command(new String[] { "env" }) + .build() + ); + assertThat(result.getStdout()).contains("TESTCONTAINERS=JAVA"); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/FileOperationsTest.java b/core/src/test/java/org/testcontainers/junit/FileOperationsTest.java new file mode 100644 index 00000000000..f2c8117a905 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/FileOperationsTest.java @@ -0,0 +1,194 @@ +package org.testcontainers.junit; + +import com.github.dockerjava.api.exception.NotFoundException; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.output.CountingOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.testcontainers.TestImages; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; +import org.testcontainers.utility.MountableFile; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FileOperationsTest { + + @TempDir + public Path temporaryFolder; + + @Test + void copyFileToContainerFileTest() throws Exception { + try ( + GenericContainer alpineCopyToContainer = new GenericContainer(TestImages.ALPINE_IMAGE) // + .withCommand("top") + ) { + alpineCopyToContainer.start(); + final MountableFile mountableFile = MountableFile.forClasspathResource("test_copy_to_container.txt"); + alpineCopyToContainer.copyFileToContainer(mountableFile, "/test.txt"); + + File actualFile = new File(temporaryFolder.toFile().getAbsolutePath() + "/test_copy_to_container.txt"); + alpineCopyToContainer.copyFileFromContainer("/test.txt", actualFile.getPath()); + + File expectedFile = new File(mountableFile.getResolvedPath()); + assertThat(FileUtils.contentEquals(expectedFile, actualFile)).as("Files aren't same ").isTrue(); + } + } + + @Test + void copyLargeFilesToContainer() throws Exception { + File tempFile = temporaryFolder.resolve("large-file").toFile(); + try ( + GenericContainer alpineCopyToContainer = new GenericContainer<>(TestImages.ALPINE_IMAGE) // + .withCommand("sleep", "infinity") + ) { + alpineCopyToContainer.start(); + final long byteCount; + try ( + FileOutputStream fos = new FileOutputStream(tempFile); + CountingOutputStream cos = new CountingOutputStream(fos); + BufferedOutputStream bos = new BufferedOutputStream(cos) + ) { + for (int i = 0; i < 0x4000; i++) { + byte[] bytes = new byte[0xFFFF]; + bos.write(bytes); + } + bos.flush(); + byteCount = cos.getByteCount(); + } + final MountableFile mountableFile = MountableFile.forHostPath(tempFile.getPath()); + final String containerPath = "/test.bin"; + alpineCopyToContainer.copyFileToContainer(mountableFile, containerPath); + + final Container.ExecResult execResult = alpineCopyToContainer.execInContainer( // + "stat", + "-c", + "%s", + containerPath + ); + assertThat(execResult.getStdout()).isEqualToIgnoringNewLines(Long.toString(byteCount)); + } finally { + tempFile.delete(); + } + } + + @Test + void copyFileToContainerFolderTest() throws Exception { + try ( + GenericContainer alpineCopyToContainer = new GenericContainer(TestImages.ALPINE_IMAGE) // + .withCommand("top") + ) { + alpineCopyToContainer.start(); + final MountableFile mountableFile = MountableFile.forClasspathResource("test_copy_to_container.txt"); + alpineCopyToContainer.copyFileToContainer(mountableFile, "/home/"); + + File actualFile = new File(temporaryFolder.toFile().getAbsolutePath() + "/test_copy_to_container.txt"); + alpineCopyToContainer.copyFileFromContainer("/home/test_copy_to_container.txt", actualFile.getPath()); + + File expectedFile = new File(mountableFile.getResolvedPath()); + assertThat(FileUtils.contentEquals(expectedFile, actualFile)).as("Files aren't same ").isTrue(); + } + } + + @Test + void copyFolderToContainerFolderTest() throws Exception { + try ( + GenericContainer alpineCopyToContainer = new GenericContainer(TestImages.ALPINE_IMAGE) // + .withCommand("top") + ) { + alpineCopyToContainer.start(); + final MountableFile mountableFile = MountableFile.forClasspathResource("mappable-resource/"); + alpineCopyToContainer.copyFileToContainer(mountableFile, "/home/test/"); + + File actualFile = new File(temporaryFolder.toFile().getAbsolutePath() + "/test_copy_to_container.txt"); + alpineCopyToContainer.copyFileFromContainer("/home/test/test-resource.txt", actualFile.getPath()); + + File expectedFile = new File(mountableFile.getResolvedPath() + "/test-resource.txt"); + assertThat(FileUtils.contentEquals(expectedFile, actualFile)).as("Files aren't same ").isTrue(); + } + } + + @Test + void copyFromContainerShouldFailBecauseNoFileTest() { + assertThatThrownBy(() -> { + try ( + GenericContainer alpineCopyToContainer = new GenericContainer(TestImages.ALPINE_IMAGE) // + .withCommand("top") + ) { + alpineCopyToContainer.start(); + alpineCopyToContainer.copyFileFromContainer( + "/home/test.txt", + "src/test/resources/copy-from/test.txt" + ); + } + }) + .isInstanceOf(NotFoundException.class); + } + + @Test + void shouldCopyFileFromContainerTest() throws IOException { + try ( + GenericContainer alpineCopyToContainer = new GenericContainer(TestImages.ALPINE_IMAGE) // + .withCommand("top") + ) { + alpineCopyToContainer.start(); + final MountableFile mountableFile = MountableFile.forClasspathResource("test_copy_to_container.txt"); + alpineCopyToContainer.copyFileToContainer(mountableFile, "/home/"); + + File actualFile = new File(temporaryFolder.toFile().getAbsolutePath() + "/test_copy_from_container.txt"); + alpineCopyToContainer.copyFileFromContainer("/home/test_copy_to_container.txt", actualFile.getPath()); + + File expectedFile = new File(mountableFile.getResolvedPath()); + assertThat(FileUtils.contentEquals(expectedFile, actualFile)).as("Files aren't same ").isTrue(); + } + } + + @Test + void copyFileOperationsShouldFailWhenNotStartedTest() { + try (GenericContainer container = new GenericContainer<>(TestImages.ALPINE_IMAGE).withCommand("top")) { + assertThatThrownBy(() -> { + MountableFile mountableFile = MountableFile.forClasspathResource("test_copy_to_container.txt"); + container.copyFileToContainer(mountableFile, "/home/test.txt"); + }) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("can only be used with created / running container"); + + assertThatThrownBy(() -> { + container.copyFileFromContainer("/home/test_copy_to_container.txt", IOUtils::toByteArray); + }) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("can only be used when the Container is created"); + } + } + + @Test + void shouldCopyFileFromExitedContainerTest() { + try ( + GenericContainer container = new GenericContainer<>(TestImages.ALPINE_IMAGE) + .withCommand("sh", "-c", "echo -n 'Hello!' > /home/file_in_container.txt") + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + ) { + container.start(); + assertThat( + container.getDockerClient().waitContainerCmd(container.getContainerId()).start().awaitStatusCode() + ) + .isZero(); + + container.copyFileFromContainer("/home/file_in_container.txt", IOUtils::toByteArray); + + container.copyFileToContainer( + MountableFile.forClasspathResource("test_copy_to_container.txt"), + "/test.txt" + ); + } + } +} diff --git a/core/src/test/java/org/testcontainers/junit/FixedHostPortContainerTest.java b/core/src/test/java/org/testcontainers/junit/FixedHostPortContainerTest.java index 516fcb8516a..38f1f5c9a6f 100644 --- a/core/src/test/java/org/testcontainers/junit/FixedHostPortContainerTest.java +++ b/core/src/test/java/org/testcontainers/junit/FixedHostPortContainerTest.java @@ -1,10 +1,18 @@ package org.testcontainers.junit; -import org.junit.Test; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.testcontainers.TestImages; import org.testcontainers.containers.FixedHostPortGenericContainer; import org.testcontainers.containers.GenericContainer; +import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStreamReader; +import java.net.Socket; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; /** * Test of {@link FixedHostPortGenericContainer}. Note that this is not an example of typical use (usually, a container @@ -12,30 +20,78 @@ * is managed completely within the test method to allow a free port to be found and assigned before the container * is started. */ -public class FixedHostPortContainerTest { +class FixedHostPortContainerTest { + + private static final String TEST_IMAGE = "alpine:3.17"; + + /** + * Default http server port (just something different from default) + */ + private static final int TEST_PORT = 5678; - private static final int REDIS_PORT = 6379; + /** + * test response + */ + private static final String TEST_RESPONSE = "test-response"; + + /** + * *nix pipe to fire test response on test port + */ + private static final String HTTP_ECHO_CMD = String.format( + "while true; do echo \"%s\" | nc -l -p %d; done", + TEST_RESPONSE, + TEST_PORT + ); @Test - public void testFixedHostPortMapping() throws IOException { + void testFixedHostPortMapping() throws IOException { // first find a free port on the docker host that will work for testing - GenericContainer portDiscoveryRedis = new GenericContainer("redis:3.0.2").withExposedPorts(REDIS_PORT); - portDiscoveryRedis.start(); - Integer freePort = portDiscoveryRedis.getMappedPort(REDIS_PORT); - portDiscoveryRedis.stop(); - - - // Set up a FixedHostPortGenericContainer as if this were a @Rule - FixedHostPortGenericContainer redis = new FixedHostPortGenericContainer("redis:3.0.2").withFixedExposedPort(freePort, REDIS_PORT); - redis.start(); - -// Config redisConfig = new Config(); -// redisConfig.useSingleServer().setAddress(redis.getContainerIpAddress() + ":" + freePort); -// Redisson redisson = Redisson.create(redisConfig); -// -// redisson.getBucket("test").set("foo"); -// -// assertEquals("The bucket content was successfully set", "foo", redisson.getBucket("test").get()); -// assertEquals("The container returns the fixed port from getMappedPort(...)", freePort, redis.getMappedPort(REDIS_PORT)); + final Integer unusedHostPort; + try ( + final GenericContainer echoServer = new GenericContainer(TestImages.TINY_IMAGE) + .withExposedPorts(TEST_PORT) + .withCommand("/bin/sh", "-c", HTTP_ECHO_CMD) + ) { + echoServer.start(); + unusedHostPort = echoServer.getMappedPort(TEST_PORT); + } + + // now starting echo server container mapped to known-as-free host port + try ( + final GenericContainer echoServer = new FixedHostPortGenericContainer(TEST_IMAGE) + // using workaround for port bind+expose + .withFixedExposedPort(unusedHostPort, TEST_PORT) + .withExposedPorts(TEST_PORT) + .withCommand("/bin/sh", "-c", HTTP_ECHO_CMD) + ) { + echoServer.start(); + + assertThat(echoServer.getMappedPort(TEST_PORT)) + .as("Port mapping does not seem to match given fixed port") + .isEqualTo(unusedHostPort); + + final String content = readResponse(echoServer, unusedHostPort); + assertThat(content).as("Returned echo from fixed port does not match expected").isEqualTo(TEST_RESPONSE); + } + } + + /** + * Simple socket content reader from given container:port + * + * @param container to query + * @param port to send request to + * @return socket reader content + * @throws IOException if any + */ + private String readResponse(GenericContainer container, Integer port) throws IOException { + try ( + Socket socket = Awaitility + .await() + .pollDelay(Duration.ofSeconds(1)) + .until(() -> new Socket(container.getHost(), port), Socket::isConnected); + BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())) + ) { + return reader.readLine(); + } } } diff --git a/core/src/test/java/org/testcontainers/junit/GenericContainerRuleTest.java b/core/src/test/java/org/testcontainers/junit/GenericContainerRuleTest.java index 59d9cb2b34f..5719f3e0268 100644 --- a/core/src/test/java/org/testcontainers/junit/GenericContainerRuleTest.java +++ b/core/src/test/java/org/testcontainers/junit/GenericContainerRuleTest.java @@ -1,47 +1,73 @@ package org.testcontainers.junit; +import com.github.dockerjava.api.model.HostConfig; import com.google.common.collect.ImmutableMap; import com.google.common.util.concurrent.Uninterruptibles; import com.mongodb.MongoClient; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; -import com.rabbitmq.client.*; +import com.rabbitmq.client.AMQP; +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.ConnectionFactory; +import com.rabbitmq.client.DefaultConsumer; +import com.rabbitmq.client.Envelope; +import org.apache.commons.io.FileUtils; import org.bson.Document; -import org.junit.*; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; import org.rnorth.ducttape.RetryCountExceededException; import org.rnorth.ducttape.unreliables.Unreliables; +import org.testcontainers.TestImages; +import org.testcontainers.containers.BindMode; +import org.testcontainers.containers.Container; import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.SelinuxContext; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; import org.testcontainers.utility.Base58; -import org.testcontainers.utility.TestEnvironment; -import java.io.*; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintStream; import java.net.Socket; import java.time.Duration; import java.util.Arrays; +import java.util.Collections; +import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.regex.Matcher; import java.util.regex.Pattern; -import static org.rnorth.visibleassertions.VisibleAssertions.*; -import static org.testcontainers.containers.BindMode.READ_ONLY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; /** * Tests for GenericContainerRules */ -public class GenericContainerRuleTest { +class GenericContainerRuleTest { private static final int REDIS_PORT = 6379; + private static final String RABBIQMQ_TEST_EXCHANGE = "TestExchange"; + private static final String RABBITMQ_TEST_ROUTING_KEY = "TestRoutingKey"; + private static final String RABBITMQ_TEST_MESSAGE = "Hello world"; + private static final int RABBITMQ_PORT = 5672; + private static final int MONGO_PORT = 27017; /* * Test data setup */ - @BeforeClass + @BeforeAll public static void setupContent() throws FileNotFoundException { File contentFolder = new File(System.getProperty("user.home") + "/.tmp-test-container"); contentFolder.mkdir(); @@ -51,92 +77,114 @@ public static void setupContent() throws FileNotFoundException { /** * Redis */ - @ClassRule - public static GenericContainer redis = new GenericContainer("redis:3.0.2") - .withExposedPorts(REDIS_PORT); + public static GenericContainer redis = new GenericContainer<>(TestImages.REDIS_IMAGE) + .withExposedPorts(REDIS_PORT); /** * RabbitMQ */ - @ClassRule - public static GenericContainer rabbitMq = new GenericContainer("rabbitmq:3.5.3") - .withExposedPorts(RABBITMQ_PORT); + public static GenericContainer rabbitMq = new GenericContainer<>(TestImages.RABBITMQ_IMAGE) + .withExposedPorts(RABBITMQ_PORT); /** * MongoDB */ - @ClassRule - public static GenericContainer mongo = new GenericContainer("mongo:3.1.5") - .withExposedPorts(MONGO_PORT); + public static GenericContainer mongo = new GenericContainer<>(TestImages.MONGODB_IMAGE) + .withExposedPorts(MONGO_PORT); /** * Pass an environment variable to the container, then run a shell script that exposes the variable in a quick and * dirty way for testing. */ - @ClassRule - public static GenericContainer alpineEnvVar = new GenericContainer("alpine:3.2") - .withExposedPorts(80) - .withEnv("MAGIC_NUMBER", "42") - .withCommand("/bin/sh", "-c", "while true; do echo \"$MAGIC_NUMBER\" | nc -l -p 80; done"); + public static GenericContainer alpineEnvVar = new GenericContainer<>(TestImages.ALPINE_IMAGE) + .withExposedPorts(80) + .withEnv("MAGIC_NUMBER", "4") + .withEnv("MAGIC_NUMBER", oldValue -> oldValue.orElse("") + "2") + .withCommand("/bin/sh", "-c", "while true; do echo \"$MAGIC_NUMBER\" | nc -l -p 80; done"); /** * Pass environment variables to the container, then run a shell script that exposes the variables in a quick and * dirty way for testing. */ - @ClassRule - public static GenericContainer alpineEnvVarFromMap = new GenericContainer("alpine:3.2") - .withExposedPorts(80) - .withEnv(ImmutableMap.of( - "FIRST", "42", - "SECOND", "50" - )) - .withCommand("/bin/sh", "-c", "while true; do echo \"$FIRST and $SECOND\" | nc -l -p 80; done"); + public static GenericContainer alpineEnvVarFromMap = new GenericContainer<>(TestImages.ALPINE_IMAGE) + .withExposedPorts(80) + .withEnv(ImmutableMap.of("FIRST", "42", "SECOND", "50")) + .withCommand("/bin/sh", "-c", "while true; do echo \"$FIRST and $SECOND\" | nc -l -p 80; done"); + + /** + * Map a file on the classpath to a file in the container, and then expose the content for testing. + */ + public static GenericContainer alpineClasspathResource = new GenericContainer<>(TestImages.ALPINE_IMAGE) + .withExposedPorts(80) + .withClasspathResourceMapping("mappable-resource/test-resource.txt", "/content.txt", BindMode.READ_ONLY) + .withCommand("/bin/sh", "-c", "while true; do cat /content.txt | nc -l -p 80; done"); /** * Map a file on the classpath to a file in the container, and then expose the content for testing. */ - @ClassRule - public static GenericContainer alpineClasspathResource = new GenericContainer("alpine:3.2") - .withExposedPorts(80) - .withClasspathResourceMapping("mappable-resource/test-resource.txt", "/content.txt", READ_ONLY) - .withCommand("/bin/sh", "-c", "while true; do cat /content.txt | nc -l -p 80; done"); + public static GenericContainer alpineClasspathResourceSelinux = new GenericContainer<>(TestImages.ALPINE_IMAGE) + .withExposedPorts(80) + .withClasspathResourceMapping( + "mappable-resource/test-resource.txt", + "/content.txt", + BindMode.READ_WRITE, + SelinuxContext.SHARED + ) + .withCommand("/bin/sh", "-c", "while true; do cat /content.txt | nc -l -p 80; done"); /** * Create a container with an extra host entry and expose the content of /etc/hosts for testing. */ - @ClassRule - public static GenericContainer alpineExtrahost = new GenericContainer("alpine:3.2") - .withExposedPorts(80) - .withExtraHost("somehost", "192.168.1.10") - .withCommand("/bin/sh", "-c", "while true; do cat /etc/hosts | nc -l -p 80; done"); - -// @Test -// public void simpleRedisTest() { -// String ipAddress = redis.getContainerIpAddress(); -// Integer port = redis.getMappedPort(REDIS_PORT); -// -// // Use Redisson to obtain a List that is backed by Redis -// Config redisConfig = new Config(); -// redisConfig.useSingleServer().setAddress(ipAddress + ":" + port); -// -// Redisson redisson = Redisson.create(redisConfig); -// -// List testList = redisson.getList("test"); -// testList.add("foo"); -// testList.add("bar"); -// testList.add("baz"); -// -// List testList2 = redisson.getList("test"); -// assertEquals("The list contains the expected number of items (redis is working!)", 3, testList2.size()); -// assertTrue("The list contains an item that was put in (redis is working!)", testList2.contains("foo")); -// assertTrue("The list contains an item that was put in (redis is working!)", testList2.contains("bar")); -// assertTrue("The list contains an item that was put in (redis is working!)", testList2.contains("baz")); -// } + public static GenericContainer alpineExtrahost = new GenericContainer<>(TestImages.ALPINE_IMAGE) + .withExposedPorts(80) + .withExtraHost("somehost", "192.168.1.10") + .withCommand("/bin/sh", "-c", "while true; do cat /etc/hosts | nc -l -p 80; done"); + + static { + redis.start(); + rabbitMq.start(); + mongo.start(); + alpineEnvVar.start(); + alpineEnvVarFromMap.start(); + alpineClasspathResource.start(); + alpineClasspathResourceSelinux.start(); + alpineExtrahost.start(); + } @Test - public void simpleRabbitMqTest() throws IOException, TimeoutException { + void testIsRunning() { + try (GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE).withCommand("top")) { + assertThat(container.isRunning()).as("Container is not started and not running").isFalse(); + container.start(); + assertThat(container.isRunning()).as("Container is started and running").isTrue(); + } + } + + @Test + void withTmpFsTest() throws Exception { + try ( + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) + .withCommand("top") + .withTmpFs(Collections.singletonMap("/testtmpfs", "rw")) + ) { + container.start(); + // check file doesn't exist + String path = "/testtmpfs/test.file"; + Container.ExecResult execResult = container.execInContainer("ls", path); + assertThat(execResult.getStderr()) + .as("tmpfs inside container works fine") + .isEqualTo("ls: /testtmpfs/test.file: No such file or directory\n"); + // touch && check file does exist + container.execInContainer("touch", path); + execResult = container.execInContainer("ls", path); + assertThat(execResult.getStdout()).as("tmpfs inside container works fine").isEqualTo(path + "\n"); + } + } + + @Test + void simpleRabbitMqTest() throws IOException, TimeoutException { ConnectionFactory factory = new ConnectionFactory(); - factory.setHost(rabbitMq.getContainerIpAddress()); + factory.setHost(rabbitMq.getHost()); factory.setPort(rabbitMq.getMappedPort(RABBITMQ_PORT)); Connection connection = factory.newConnection(); @@ -147,72 +195,136 @@ public void simpleRabbitMqTest() throws IOException, TimeoutException { // Set up a consumer on the queue final boolean[] messageWasReceived = new boolean[1]; - channel.basicConsume(queueName, false, new DefaultConsumer(channel) { - @Override - public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException { - messageWasReceived[0] = Arrays.equals(body, RABBITMQ_TEST_MESSAGE.getBytes()); + channel.basicConsume( + queueName, + false, + new DefaultConsumer(channel) { + @Override + public void handleDelivery( + String consumerTag, + Envelope envelope, + AMQP.BasicProperties properties, + byte[] body + ) throws IOException { + messageWasReceived[0] = Arrays.equals(body, RABBITMQ_TEST_MESSAGE.getBytes()); + } } - }); + ); // post a message channel.basicPublish(RABBIQMQ_TEST_EXCHANGE, RABBITMQ_TEST_ROUTING_KEY, null, RABBITMQ_TEST_MESSAGE.getBytes()); // check the message was received - assertTrue("The message was received", Unreliables.retryUntilSuccess(5, TimeUnit.SECONDS, () -> { - if (!messageWasReceived[0]) { - throw new IllegalStateException("Message not received yet"); - } - return true; - })); + assertThat( + Unreliables.retryUntilSuccess( + 5, + TimeUnit.SECONDS, + () -> { + if (!messageWasReceived[0]) { + throw new IllegalStateException("Message not received yet"); + } + return true; + } + ) + ) + .as("The message was received") + .isTrue(); } @Test - public void simpleMongoDbTest() { - MongoClient mongoClient = new MongoClient(mongo.getContainerIpAddress(), mongo.getMappedPort(MONGO_PORT)); + void simpleMongoDbTest() { + MongoClient mongoClient = new MongoClient(mongo.getHost(), mongo.getMappedPort(MONGO_PORT)); MongoDatabase database = mongoClient.getDatabase("test"); MongoCollection collection = database.getCollection("testCollection"); - Document doc = new Document("name", "foo") - .append("value", 1); + Document doc = new Document("name", "foo").append("value", 1); collection.insertOne(doc); Document doc2 = collection.find(new Document("name", "foo")).first(); - assertEquals("A record can be inserted into and retrieved from MongoDB", 1, doc2.get("value")); + assertThat(doc2.get("value")).as("A record can be inserted into and retrieved from MongoDB").isEqualTo(1); } @Test - public void environmentAndCustomCommandTest() throws IOException { + void environmentAndCustomCommandTest() throws IOException { String line = getReaderForContainerPort80(alpineEnvVar).readLine(); - assertEquals("An environment variable can be passed into a command", "42", line); + assertThat(line).as("An environment variable can be passed into a command").isEqualTo("42"); } @Test - public void environmentFromMapTest() throws IOException { + void environmentFromMapTest() throws IOException { String line = getReaderForContainerPort80(alpineEnvVarFromMap).readLine(); - assertEquals("Environment variables can be passed into a command from a map", "42 and 50", line); + assertThat(line).as("Environment variables can be passed into a command from a map").isEqualTo("42 and 50"); + } + + @Test + void customLabelTest() { + try ( + final GenericContainer alpineCustomLabel = new GenericContainer<>(TestImages.ALPINE_IMAGE) + .withLabel("our.custom", "label") + .withCommand("top") + .withCreateContainerCmdModifier(cmd -> cmd.getLabels().put("scope", "local")) + ) { + alpineCustomLabel.start(); + + Map labels = alpineCustomLabel.getCurrentContainerInfo().getConfig().getLabels(); + assertThat(labels).as("org.testcontainers label is present").containsKey("org.testcontainers"); + assertThat(labels).as("org.testcontainers.lang label is present").containsKey("org.testcontainers.lang"); + assertThat(labels) + .as("org.testcontainers.lang label is present") + .containsEntry("org.testcontainers.lang", "java"); + assertThat(labels) + .as("org.testcontainers.version label is present") + .containsKey("org.testcontainers.version"); + assertThat(labels).as("our.custom label is present").containsKey("our.custom"); + assertThat(labels).as("our.custom label value is label").containsEntry("our.custom", "label"); + assertThat(labels) + .as("project label value is testcontainers-java") + .containsEntry("project", "testcontainers-java"); + assertThat(labels).as("scope label value is local").containsEntry("scope", "local"); + } + } + + @Test + void exceptionThrownWhenTryingToOverrideTestcontainersLabels() { + assertThat( + catchThrowable(() -> { + new GenericContainer<>(TestImages.ALPINE_IMAGE).withLabel("org.testcontainers.foo", "false"); + }) + ) + .as("When trying to overwrite an 'org.testcontainers' label, withLabel() throws an exception") + .isInstanceOf(IllegalArgumentException.class); } @Test - public void customClasspathResourceMappingTest() throws IOException { + void customClasspathResourceMappingTest() throws IOException { // Note: This functionality doesn't work if you are running your build inside a Docker container; // in that case this test will fail. String line = getReaderForContainerPort80(alpineClasspathResource).readLine(); - assertEquals("Resource on the classpath can be mapped using calls to withClasspathResourceMapping", "FOOBAR", line); + assertThat(line) + .as("Resource on the classpath can be mapped using calls to withClasspathResourceMapping") + .isEqualTo("FOOBAR"); } @Test - public void exceptionThrownWhenMappedPortNotFound() throws IOException { - assertThrows("When the requested port is not mapped, getMappedPort() throws an exception", - IllegalArgumentException.class, - () -> { - return redis.getMappedPort(666); - }); + void customClasspathResourceMappingWithSelinuxTest() throws IOException { + String line = getReaderForContainerPort80(alpineClasspathResourceSelinux).readLine(); + assertThat(line) + .as("Resource on the classpath can be mapped using calls to withClasspathResourceMappingSelinux") + .isEqualTo("FOOBAR"); + } + + @Test + void exceptionThrownWhenMappedPortNotFound() { + assertThat(catchThrowable(() -> redis.getMappedPort(666))) + .as("When the requested port is not mapped, getMappedPort() throws an exception") + .isInstanceOf(IllegalArgumentException.class); } - protected static void writeStringToFile(File contentFolder, String filename, String string) throws FileNotFoundException { + protected static void writeStringToFile(File contentFolder, String filename, String string) + throws FileNotFoundException { File file = new File(contentFolder, filename); PrintStream printStream = new PrintStream(new FileOutputStream(file)); @@ -220,54 +332,35 @@ protected static void writeStringToFile(File contentFolder, String filename, Str printStream.close(); } - @Test @Ignore //TODO investigate intermittent failures - public void failFastWhenContainerHaltsImmediately() throws Exception { - - long startingTimeMs = System.currentTimeMillis(); - final GenericContainer failsImmediately = new GenericContainer("alpine:3.2") - .withCommand("/bin/sh", "-c", "return false") - .withMinimumRunningDuration(Duration.ofMillis(100)); + @Test + @Disabled //TODO investigate intermittent failures + void failFastWhenContainerHaltsImmediately() { + long startingTimeNano = System.nanoTime(); + final GenericContainer failsImmediately = new GenericContainer<>(TestImages.ALPINE_IMAGE) + .withCommand("/bin/sh", "-c", "return false") + .withMinimumRunningDuration(Duration.ofMillis(100)); try { - assertThrows( - "When we start a container that halts immediately, an exception is thrown", - RetryCountExceededException.class, - () -> { - failsImmediately.start(); - return null; - }); + assertThat(catchThrowable(failsImmediately::start)) + .as("When we start a container that halts immediately, an exception is thrown") + .isInstanceOf(RetryCountExceededException.class); // Check how long it took, to verify that we ARE bailing out early. // Want to strike a balance here; too short and this test will fail intermittently - // on slow systems and/or due to GC variation, too long and we won't properly test + // on slow systems and/or due to GC variation, too long, and we won't properly test // what we're intending to test. - int allowedSecondsToFailure = - GenericContainer.CONTAINER_RUNNING_TIMEOUT_SEC / 2; - long completedTimeMs = System.currentTimeMillis(); - assertTrue("container should not take long to start up", - completedTimeMs - startingTimeMs < 1000L * allowedSecondsToFailure); + int allowedSecondsToFailure = GenericContainer.CONTAINER_RUNNING_TIMEOUT_SEC / 2; + long completedTimeNano = System.nanoTime(); + assertThat(completedTimeNano - startingTimeNano < TimeUnit.SECONDS.toNanos(allowedSecondsToFailure)) + .as("container should not take long to start up") + .isTrue(); } finally { failsImmediately.stop(); } } @Test - public void testExecInContainer() throws Exception { - - // The older "lxc" execution driver doesn't support "exec". At the time of writing (2016/03/29), - // that's the case for CircleCI. - // Once they resolve the issue, this clause can be removed. - Assume.assumeTrue(TestEnvironment.dockerExecutionDriverSupportsExec()); - - final GenericContainer.ExecResult result = redis.execInContainer("redis-cli", "role"); - assertTrue("Output for \"redis-cli role\" command should start with \"master\"", result.getStdout().startsWith("master")); - assertEquals("Stderr for \"redis-cli role\" command should be empty", "", result.getStderr()); - // We expect to reach this point for modern Docker versions. - } - - - @Test - public void extraHostTest() throws IOException { + void extraHostTest() throws IOException { BufferedReader br = getReaderForContainerPort80(alpineExtrahost); // read hosts file from container @@ -280,36 +373,74 @@ public void extraHostTest() throws IOException { } Matcher matcher = Pattern.compile("^192.168.1.10\\s.*somehost", Pattern.MULTILINE).matcher(hosts.toString()); - assertTrue("The hosts file of container contains extra host", matcher.find()); + assertThat(matcher.find()).as("The hosts file of container contains extra host").isTrue(); } @Test - public void createContainerCmdHookTest() { + void createContainerCmdHookTest() { // Use random name to avoid the conflicts between the tests String randomName = Base58.randomString(5); - try( - GenericContainer container = new GenericContainer<>("redis:3.0.2") - .withCommand("redis-server", "--help") - .withCreateContainerCmdModifier(cmd -> cmd.withName("overrideMe")) - // Preserves the order - .withCreateContainerCmdModifier(cmd -> cmd.withName(randomName)) - // Allows to override pre-configured values by GenericContainer - .withCreateContainerCmdModifier(cmd -> cmd.withCmd("redis-server", "--port", "6379")) + try ( + GenericContainer container = new GenericContainer<>(TestImages.REDIS_IMAGE) + .withCommand("redis-server", "--help") + .withCreateContainerCmdModifier(cmd -> cmd.withName("overrideMe")) + // Preserves the order + .withCreateContainerCmdModifier(cmd -> cmd.withName(randomName)) + // Allows to override pre-configured values by GenericContainer + .withCreateContainerCmdModifier(cmd -> cmd.withCmd("redis-server", "--port", "6379")) ) { container.start(); - assertEquals("Name is configured", "/" + randomName, container.getContainerInfo().getName()); - assertEquals("Command is configured", "[redis-server, --port, 6379]", Arrays.toString(container.getContainerInfo().getConfig().getCmd())); + assertThat(container.getContainerInfo().getName()).as("Name is configured").isEqualTo("/" + randomName); + assertThat(Arrays.toString(container.getContainerInfo().getConfig().getCmd())) + .as("Command is configured") + .isEqualTo("[redis-server, --port, 6379]"); } } private BufferedReader getReaderForContainerPort80(GenericContainer container) { + return Unreliables.retryUntilSuccess( + 10, + TimeUnit.SECONDS, + () -> { + Uninterruptibles.sleepUninterruptibly(1, TimeUnit.SECONDS); + + Socket socket = new Socket(container.getHost(), container.getFirstMappedPort()); + return new BufferedReader(new InputStreamReader(socket.getInputStream())); + } + ); + } - return Unreliables.retryUntilSuccess(10, TimeUnit.SECONDS, () -> { - Uninterruptibles.sleepUninterruptibly(1, TimeUnit.SECONDS); + @Test + void addExposedPortAfterWithExposedPortsTest() { + redis.addExposedPort(8987); + assertThat(redis.getExposedPorts()).as("Both ports should be exposed").hasSize(2); + assertThat(redis.getExposedPorts()).as("withExposedPort should be exposed").contains(REDIS_PORT); + assertThat(redis.getExposedPorts()).as("addExposedPort should be exposed").contains(8987); + } - Socket socket = new Socket(container.getContainerIpAddress(), container.getMappedPort(80)); - return new BufferedReader(new InputStreamReader(socket.getInputStream())); - }); + @Test + void addingExposedPortTwiceShouldNotFail() { + redis.addExposedPort(8987); + redis.addExposedPort(8987); + assertThat(redis.getExposedPorts()).as("Both ports should be exposed").hasSize(2); // 2 ports = de-duplicated port 8897 and original port 6379 + assertThat(redis.getExposedPorts()).as("withExposedPort should be exposed").contains(REDIS_PORT); + assertThat(redis.getExposedPorts()).as("addExposedPort should be exposed").contains(8987); + } + + @Test + void sharedMemorySetTest() { + try ( + GenericContainer containerWithSharedMemory = new GenericContainer<>(TestImages.TINY_IMAGE) + .withSharedMemorySize(42L * FileUtils.ONE_MB) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + ) { + containerWithSharedMemory.start(); + + HostConfig hostConfig = containerWithSharedMemory.getContainerInfo().getHostConfig(); + assertThat(hostConfig.getShmSize()) + .as("Shared memory not set on container") + .isEqualTo(42L * FileUtils.ONE_MB); + } } } diff --git a/core/src/test/java/org/testcontainers/junit/NonExistentImagePullTest.java b/core/src/test/java/org/testcontainers/junit/NonExistentImagePullTest.java index 22c7b4e9d9c..2978777aae7 100644 --- a/core/src/test/java/org/testcontainers/junit/NonExistentImagePullTest.java +++ b/core/src/test/java/org/testcontainers/junit/NonExistentImagePullTest.java @@ -1,22 +1,28 @@ package org.testcontainers.junit; -import org.junit.Test; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.testcontainers.containers.ContainerFetchException; import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThrows; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; /** * Created by rnorth on 20/03/2016. */ -public class NonExistentImagePullTest { +class NonExistentImagePullTest { - @Test(timeout = 60_000L) - public void pullingNonExistentImageFailsGracefully() { - - assertThrows("Pulling a nonexistent container will cause an exception to be thrown", - ContainerFetchException.class, () -> { - return new GenericContainer("richnorth/nonexistent:latest"); - }); + @Test + @Timeout(60) + void pullingNonExistentImageFailsGracefully() { + assertThat( + catchThrowable(() -> { + new GenericContainer<>(DockerImageName.parse("testcontainers/nonexistent:latest")).getDockerImageName(); + }) + ) + .as("Pulling a nonexistent container will cause an exception to be thrown") + .isInstanceOf(ContainerFetchException.class); } } diff --git a/core/src/test/java/org/testcontainers/junit/OutputStreamTest.java b/core/src/test/java/org/testcontainers/junit/OutputStreamTest.java index a14e1e32237..42fc7ff584b 100644 --- a/core/src/test/java/org/testcontainers/junit/OutputStreamTest.java +++ b/core/src/test/java/org/testcontainers/junit/OutputStreamTest.java @@ -1,9 +1,12 @@ package org.testcontainers.junit; -import org.junit.Rule; -import org.junit.Test; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.testcontainers.TestImages; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.output.OutputFrame; import org.testcontainers.containers.output.Slf4jLogConsumer; @@ -14,70 +17,96 @@ import java.util.concurrent.TimeoutException; import java.util.function.Consumer; -import static org.rnorth.visibleassertions.VisibleAssertions.*; -import static org.testcontainers.containers.output.OutputFrame.OutputType.STDOUT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; /** * Simple test for following container output. */ -public class OutputStreamTest { - - @Rule - public GenericContainer container = new GenericContainer("alpine:3.2") - .withCommand("ping -c 5 www.google.com"); +class OutputStreamTest { private static final Logger LOGGER = LoggerFactory.getLogger(OutputStreamTest.class); - @Test - public void testFetchStdout() throws TimeoutException { - - WaitingConsumer consumer = new WaitingConsumer(); + public GenericContainer container = new GenericContainer(TestImages.ALPINE_IMAGE) + .withCommand("ping -c 5 127.0.0.1"); - container.followOutput(consumer, STDOUT); + @BeforeEach + void setUp() { + container.start(); + } - consumer.waitUntil(frame -> frame.getType() == STDOUT && frame.getUtf8String().contains("seq=2"), - 30, TimeUnit.SECONDS); + @AfterEach + void tearDown() { + container.stop(); } @Test - public void testFetchStdoutWithTimeout() throws TimeoutException { - + @Timeout(value = 60) + void testFetchStdout() throws TimeoutException { WaitingConsumer consumer = new WaitingConsumer(); - container.followOutput(consumer, STDOUT); + container.followOutput(consumer, OutputFrame.OutputType.STDOUT); - assertThrows("a TimeoutException should be thrown", TimeoutException.class, () -> { - consumer.waitUntil(frame -> frame.getType() == STDOUT && frame.getUtf8String().contains("seq=5"), - 2, TimeUnit.SECONDS); - return true; - }); + consumer.waitUntil( + frame -> frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().contains("seq=2"), + 30, + TimeUnit.SECONDS + ); } @Test - public void testFetchStdoutWithNoLimit() throws TimeoutException { + @Timeout(value = 60) + void testFetchStdoutWithTimeout() { + WaitingConsumer consumer = new WaitingConsumer(); + container.followOutput(consumer, OutputFrame.OutputType.STDOUT); + + assertThat( + catchThrowable(() -> { + consumer.waitUntil( + frame -> { + return ( + frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().contains("seq=5") + ); + }, + 2, + TimeUnit.SECONDS + ); + }) + ) + .as("a TimeoutException should be thrown") + .isInstanceOf(TimeoutException.class); + } + + @Test + @Timeout(value = 60) + void testFetchStdoutWithNoLimit() throws TimeoutException { WaitingConsumer consumer = new WaitingConsumer(); - container.followOutput(consumer, STDOUT); + container.followOutput(consumer, OutputFrame.OutputType.STDOUT); - consumer.waitUntil(frame -> frame.getType() == STDOUT && frame.getUtf8String().contains("seq=2")); + consumer.waitUntil(frame -> { + return frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().contains("seq=2"); + }); } @Test - public void testLogConsumer() throws TimeoutException { - + @Timeout(value = 60) + void testLogConsumer() throws TimeoutException { WaitingConsumer waitingConsumer = new WaitingConsumer(); Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(LOGGER); Consumer composedConsumer = logConsumer.andThen(waitingConsumer); container.followOutput(composedConsumer); - waitingConsumer.waitUntil(frame -> frame.getType() == STDOUT && frame.getUtf8String().contains("seq=2")); + waitingConsumer.waitUntil(frame -> { + return frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().contains("seq=2"); + }); } @Test - public void testToStringConsumer() throws TimeoutException { - + @Timeout(value = 60) + void testToStringConsumer() throws TimeoutException { WaitingConsumer waitingConsumer = new WaitingConsumer(); ToStringConsumer toStringConsumer = new ToStringConsumer(); @@ -87,9 +116,8 @@ public void testToStringConsumer() throws TimeoutException { waitingConsumer.waitUntilEnd(30, TimeUnit.SECONDS); String utf8String = toStringConsumer.toUtf8String(); - assertTrue("the expected first value was found", utf8String.contains("seq=1")); - assertTrue("the expected last value was found", utf8String.contains("seq=4")); - assertFalse("a non-expected value was found", utf8String.contains("seq=42")); + assertThat(utf8String).as("the expected first value was found").contains("seq=1"); + assertThat(utf8String).as("the expected last value was found").contains("seq=4"); + assertThat(utf8String).as("a non-expected value was found").doesNotContain("seq=42"); } } - diff --git a/core/src/test/java/org/testcontainers/junit/OutputStreamWithTTYTest.java b/core/src/test/java/org/testcontainers/junit/OutputStreamWithTTYTest.java new file mode 100644 index 00000000000..a6dbfa06343 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/OutputStreamWithTTYTest.java @@ -0,0 +1,115 @@ +package org.testcontainers.junit; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.TestImages; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.output.ToStringConsumer; +import org.testcontainers.containers.output.WaitingConsumer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +@Slf4j +@Timeout(10) +class OutputStreamWithTTYTest { + + @AutoClose + public GenericContainer container = new GenericContainer<>(TestImages.ALPINE_IMAGE) + .withCommand("ls -1") + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + .withCreateContainerCmdModifier(command -> command.withTty(true)); + + @BeforeEach + void setUp() { + container.start(); + } + + @Test + void testFetchStdout() throws TimeoutException { + WaitingConsumer consumer = new WaitingConsumer(); + + container.followOutput(consumer, OutputFrame.OutputType.STDOUT); + + consumer.waitUntil( + frame -> { + return frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().contains("home"); + }, + 4, + TimeUnit.SECONDS + ); + } + + @Test + void testFetchStdoutWithTimeout() { + WaitingConsumer consumer = new WaitingConsumer(); + + container.followOutput(consumer, OutputFrame.OutputType.STDOUT); + + assertThat( + catchThrowable(() -> { + consumer.waitUntil( + frame -> { + return ( + frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().contains("qqq") + ); + }, + 1, + TimeUnit.SECONDS + ); + }) + ) + .as("a TimeoutException should be thrown") + .isInstanceOf(TimeoutException.class); + } + + @Test + void testFetchStdoutWithNoLimit() throws TimeoutException { + WaitingConsumer consumer = new WaitingConsumer(); + + container.followOutput(consumer, OutputFrame.OutputType.STDOUT); + + consumer.waitUntil(frame -> { + return frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().contains("home"); + }); + } + + @Test + void testLogConsumer() throws TimeoutException { + WaitingConsumer waitingConsumer = new WaitingConsumer(); + Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(log); + + Consumer composedConsumer = logConsumer.andThen(waitingConsumer); + container.followOutput(composedConsumer); + + waitingConsumer.waitUntil(frame -> { + return frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().contains("home"); + }); + } + + @Test + void testToStringConsumer() throws TimeoutException { + WaitingConsumer waitingConsumer = new WaitingConsumer(); + ToStringConsumer toStringConsumer = new ToStringConsumer(); + + Consumer composedConsumer = toStringConsumer.andThen(waitingConsumer); + container.followOutput(composedConsumer); + + waitingConsumer.waitUntilEnd(4, TimeUnit.SECONDS); + + String utf8String = toStringConsumer.toUtf8String(); + assertThat(utf8String).as("the expected first value was found").contains("home"); + assertThat(utf8String).as("the expected last value was found").contains("media"); + assertThat(utf8String).as("a non-expected value was found").doesNotContain("qqq"); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/ParameterizedDockerfileContainerTest.java b/core/src/test/java/org/testcontainers/junit/ParameterizedDockerfileContainerTest.java index b0790345deb..0f98a35893b 100644 --- a/core/src/test/java/org/testcontainers/junit/ParameterizedDockerfileContainerTest.java +++ b/core/src/test/java/org/testcontainers/junit/ParameterizedDockerfileContainerTest.java @@ -1,64 +1,59 @@ package org.testcontainers.junit; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.rnorth.ducttape.unreliables.Unreliables; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.testcontainers.containers.GenericContainer; import org.testcontainers.images.builder.ImageFromDockerfile; -import java.io.IOException; -import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; /** * Simple test case / demonstration of creating a fresh container image from a Dockerfile DSL when the test * is parameterized. */ -@RunWith(Parameterized.class) -public class ParameterizedDockerfileContainerTest { +@ParameterizedClass(name = "{0}") +@MethodSource("data") +class ParameterizedDockerfileContainerTest { - @Parameterized.Parameters(name = "{0}") - public static Object[] data() { - return new Object[] { "alpine:3.2", "alpine:3.3" }; - } + private final String expectedVersion; + + public GenericContainer container; - public ParameterizedDockerfileContainerTest(String baseImage) { - container = new GenericContainer(new ImageFromDockerfile().withDockerfileFromBuilder(builder -> { - builder - .from(baseImage) - .run("apk add --update nginx") - .cmd("nginx", "-g", "daemon off;") - .build(); - })).withExposedPorts(80); + public ParameterizedDockerfileContainerTest(String baseImage, String expectedVersion) { + container = + new GenericContainer( + new ImageFromDockerfile() + .withDockerfileFromBuilder(builder -> { + builder + .from(baseImage) + // Could potentially customise the image here, e.g. adding files, running + // commands, etc. + .build(); + }) + ) + .withCommand("top"); + container.start(); + this.expectedVersion = expectedVersion; } - @Rule - public GenericContainer container; + public static Stream data() { + return Stream.of( + Arguments.of("alpine:3.12", "3.12"), + Arguments.of("alpine:3.13", "3.13"), + Arguments.of("alpine:3.14", "3.14"), + Arguments.of("alpine:3.15", "3.15"), + Arguments.of("alpine:3.16", "3.16") + ); + } @Test - public void simpleTest() throws IOException { - String address = String.format("http://%s:%s", container.getContainerIpAddress(), container.getMappedPort(80)); - - CloseableHttpClient httpClient = HttpClientBuilder.create().build(); - HttpGet get = new HttpGet(address); + void simpleTest() throws Exception { + final String release = container.execInContainer("cat", "/etc/alpine-release").getStdout(); - Unreliables.retryUntilSuccess(5, TimeUnit.SECONDS, () -> { - try (CloseableHttpResponse response = httpClient.execute(get)) { - assertEquals("A container built from a dockerfile can run nginx as expected, and returns a good status code", - 200, - response.getStatusLine().getStatusCode()); - assertTrue("A container built from a dockerfile can run nginx as expected, and returns an expected Server header", - response.getHeaders("Server")[0].getValue().contains("nginx")); - } - return true; - }); + assertThat(release).as("/etc/alpine-release starts with " + expectedVersion).startsWith(expectedVersion); } } diff --git a/core/src/test/java/org/testcontainers/junit/WorkingDirectoryTest.java b/core/src/test/java/org/testcontainers/junit/WorkingDirectoryTest.java index 1118302f701..a31b38e06ed 100644 --- a/core/src/test/java/org/testcontainers/junit/WorkingDirectoryTest.java +++ b/core/src/test/java/org/testcontainers/junit/WorkingDirectoryTest.java @@ -1,40 +1,32 @@ package org.testcontainers.junit; -import org.junit.ClassRule; -import org.junit.Test; +import org.junit.jupiter.api.Test; +import org.testcontainers.TestImages; import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.output.OutputFrame; -import org.testcontainers.containers.output.ToStringConsumer; -import org.testcontainers.containers.output.WaitingConsumer; import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; -import java.util.function.Consumer; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; /** * Created by rnorth on 26/07/2016. */ -public class WorkingDirectoryTest { +class WorkingDirectoryTest { - private static WaitingConsumer waitingConsumer = new WaitingConsumer(); - private static ToStringConsumer toStringConsumer = new ToStringConsumer(); - private static Consumer compositeConsumer = waitingConsumer.andThen(toStringConsumer); + public static GenericContainer container = new GenericContainer(TestImages.ALPINE_IMAGE) + .withWorkingDirectory("/etc") + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + .withCommand("ls", "-al"); - @ClassRule - public static GenericContainer container = new GenericContainer("alpine:3.2") - .withWorkingDirectory("/etc") - .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) - .withCommand("ls", "-al") - .withLogConsumer(compositeConsumer); + static { + container.start(); + } @Test - public void checkOutput() { - String listing = toStringConsumer.toUtf8String(); + void checkOutput() { + String listing = container.getLogs(); - assertTrue("Directory listing contains expected /etc content", listing.contains("hostname")); - assertTrue("Directory listing contains expected /etc content", listing.contains("init.d")); - assertTrue("Directory listing contains expected /etc content", listing.contains("passwd")); + assertThat(listing).as("Directory listing contains expected /etc content").contains("hostname"); + assertThat(listing).as("Directory listing contains expected /etc content").contains("init.d"); + assertThat(listing).as("Directory listing contains expected /etc content").contains("passwd"); } - } diff --git a/core/src/test/java/org/testcontainers/junit/wait/AbstractWaitStrategyTest.java b/core/src/test/java/org/testcontainers/junit/wait/AbstractWaitStrategyTest.java deleted file mode 100644 index 6578a430b69..00000000000 --- a/core/src/test/java/org/testcontainers/junit/wait/AbstractWaitStrategyTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package org.testcontainers.junit.wait; - -import org.jetbrains.annotations.NotNull; -import org.junit.Before; -import org.rnorth.ducttape.RetryCountExceededException; -import org.rnorth.visibleassertions.VisibleAssertions; -import org.testcontainers.containers.ContainerLaunchException; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.WaitStrategy; - -import java.time.Duration; -import java.util.concurrent.atomic.AtomicBoolean; - -import static org.junit.Assert.*; - -/** - * Common test methods for {@link WaitStrategy} implementations. - * - * @author Pete Cornish {@literal } - */ -public abstract class AbstractWaitStrategyTest { - private static final long WAIT_TIMEOUT_MILLIS = 3000; - private static final String IMAGE_NAME = "alpine:latest"; - - /** - * Indicates that the WaitStrategy has completed waiting successfully. - */ - private AtomicBoolean ready; - - /** - * Subclasses should return an instance of {@link W} that sets {@code ready} to {@code true}, - * if the wait was successful. - * - * @param ready the AtomicBoolean on which to indicate success - * @return WaitStrategy implementation - */ - @NotNull - protected abstract W buildWaitStrategy(final AtomicBoolean ready); - - @Before - public void setUp() throws Exception { - ready = new AtomicBoolean(false); - } - - /** - * Starts a GenericContainer with the {@link #IMAGE_NAME} image, passing the given {@code shellCommand} as - * a parameter to {@literal sh -c} (the container CMD). - * - * @param shellCommand the shell command to execute - * @return the (unstarted) container - */ - private GenericContainer startContainerWithCommand(String shellCommand) { - final WaitStrategy waitStrategy = buildWaitStrategy(ready) - .withStartupTimeout(Duration.ofMillis(WAIT_TIMEOUT_MILLIS)); - - // apply WaitStrategy to container - return new GenericContainer(IMAGE_NAME) - .withExposedPorts(8080) - .withCommand("sh", "-c", shellCommand) - .waitingFor(waitStrategy); - } - - /** - * Expects that the WaitStrategy returns successfully after connection to a container with a listening port. - * - * @param shellCommand the shell command to execute - */ - protected void waitUntilReadyAndSucceed(String shellCommand) { - final GenericContainer container = startContainerWithCommand(shellCommand); - - // start() blocks until successful or timeout - container.start(); - - assertTrue(String.format("Expected container to be ready after timeout of %sms", - WAIT_TIMEOUT_MILLIS), ready.get()); - } - - /** - * Expects that the WaitStrategy throws a {@link RetryCountExceededException} after unsuccessful connection - * to a container with a listening port. - * - * @param shellCommand the shell command to execute - */ - protected void waitUntilReadyAndTimeout(String shellCommand) { - final GenericContainer container = startContainerWithCommand(shellCommand); - - // start() blocks until successful or timeout - VisibleAssertions.assertThrows("an exception is thrown when timeout occurs (" + WAIT_TIMEOUT_MILLIS + "ms)", - ContainerLaunchException.class, - container::start); - - } -} diff --git a/core/src/test/java/org/testcontainers/junit/wait/HostPortWaitStrategyTest.java b/core/src/test/java/org/testcontainers/junit/wait/HostPortWaitStrategyTest.java deleted file mode 100644 index 114750c6d0f..00000000000 --- a/core/src/test/java/org/testcontainers/junit/wait/HostPortWaitStrategyTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.testcontainers.junit.wait; - -import org.jetbrains.annotations.NotNull; -import org.junit.Test; -import org.rnorth.ducttape.RetryCountExceededException; -import org.testcontainers.containers.wait.HostPortWaitStrategy; - -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * Tests for {@link HostPortWaitStrategy}. - * - * @author Pete Cornish {@literal } - */ -public class HostPortWaitStrategyTest extends AbstractWaitStrategyTest { - /** - * Expects that the WaitStrategy returns successfully after connection to a container with a listening port. - */ - @Test - public void testWaitUntilReady_Success() { - waitUntilReadyAndSucceed("while true; do nc -lp 8080; done"); - } - - /** - * Expects that the WaitStrategy throws a {@link RetryCountExceededException} after unsuccessful connection - * to a container with a listening port within the timeout period. - */ - @Test - public void testWaitUntilReady_Timeout() { - waitUntilReadyAndTimeout("exit 1"); - } - - /** - * @param ready the AtomicBoolean on which to indicate success - * @return the WaitStrategy under test - */ - @NotNull - protected HostPortWaitStrategy buildWaitStrategy(final AtomicBoolean ready) { - return new HostPortWaitStrategy() { - @Override - protected void waitUntilReady() { - // blocks until ready or timeout occurs - super.waitUntilReady(); - ready.set(true); - } - }; - } -} diff --git a/core/src/test/java/org/testcontainers/junit/wait/HttpWaitStrategyTest.java b/core/src/test/java/org/testcontainers/junit/wait/HttpWaitStrategyTest.java deleted file mode 100644 index 2c345ae6ca4..00000000000 --- a/core/src/test/java/org/testcontainers/junit/wait/HttpWaitStrategyTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.testcontainers.junit.wait; - -import org.jetbrains.annotations.NotNull; -import org.junit.Test; -import org.rnorth.ducttape.RetryCountExceededException; -import org.testcontainers.containers.wait.HttpWaitStrategy; - -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * Tests for {@link HttpWaitStrategy}. - * - * @author Pete Cornish {@literal } - */ -public class HttpWaitStrategyTest extends AbstractWaitStrategyTest { - /** - * Doubly-escaped newline sequence indicating end of the HTTP header. - */ - private static final String DOUBLE_NEWLINE = "\\\r\\\n\\\r\\\n"; - - /** - * Expects that the WaitStrategy returns successfully after receiving an HTTP 200 response from the container. - */ - @Test - public void testWaitUntilReady_Success() { - waitUntilReadyAndSucceed("while true; do echo -e \"HTTP/1.1 200 OK" + DOUBLE_NEWLINE + "\" | nc -lp 8080; done"); - } - - /** - * Expects that the WaitStrategy throws a {@link RetryCountExceededException} after not receiving an HTTP 200 - * response from the container within the timeout period. - */ - @Test - public void testWaitUntilReady_Timeout() { - waitUntilReadyAndTimeout("while true; do echo -e \"HTTP/1.1 400 Bad Request" + DOUBLE_NEWLINE + "\" | nc -lp 8080; done"); - } - - /** - * @param ready the AtomicBoolean on which to indicate success - * @return the WaitStrategy under test - */ - @NotNull - protected HttpWaitStrategy buildWaitStrategy(final AtomicBoolean ready) { - return new HttpWaitStrategy() { - @Override - protected void waitUntilReady() { - // blocks until ready or timeout occurs - super.waitUntilReady(); - ready.set(true); - } - }; - } -} diff --git a/core/src/test/java/org/testcontainers/junit/wait/strategy/AbstractWaitStrategyTest.java b/core/src/test/java/org/testcontainers/junit/wait/strategy/AbstractWaitStrategyTest.java new file mode 100644 index 00000000000..fa97bbc25c3 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/wait/strategy/AbstractWaitStrategyTest.java @@ -0,0 +1,128 @@ +package org.testcontainers.junit.wait.strategy; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.rnorth.ducttape.RetryCountExceededException; +import org.testcontainers.TestImages; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.WaitStrategy; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +/** + * Common test methods for {@link WaitStrategy} implementations. + */ +public abstract class AbstractWaitStrategyTest { + + static final long WAIT_TIMEOUT_MILLIS = 3000; + + /** + * Indicates that the WaitStrategy has completed waiting successfully. + */ + AtomicBoolean ready; + + /** + * Subclasses should return an instance of {@link W} that sets {@code ready} to {@code true}, + * if the wait was successful. + * + * @param ready the AtomicBoolean on which to indicate success + * @return WaitStrategy implementation + */ + @NotNull + protected abstract W buildWaitStrategy(final AtomicBoolean ready); + + @BeforeEach + public void setUp() { + ready = new AtomicBoolean(false); + } + + /** + * Starts a GenericContainer with the Alpine image, passing the given {@code shellCommand} as + * a parameter to {@literal sh -c} (the container CMD). + * + * @param shellCommand the shell command to execute + * @return the (unstarted) container + */ + private GenericContainer startContainerWithCommand(String shellCommand) { + return startContainerWithCommand(shellCommand, buildWaitStrategy(ready)); + } + + /** + * Starts a GenericContainer with the Alpine image, passing the given {@code shellCommand} as + * a parameter to {@literal sh -c} (the container CMD) and apply a give wait strategy. + * Note that the timeout will be overwritten if any with {@link #WAIT_TIMEOUT_MILLIS}. + * @param shellCommand the shell command to execute + * @param waitStrategy The wait strategy to apply + * @return the (unstarted) container + */ + protected GenericContainer startContainerWithCommand(String shellCommand, WaitStrategy waitStrategy) { + return startContainerWithCommand(shellCommand, waitStrategy, 8080); + } + + protected GenericContainer startContainerWithCommand( + String shellCommand, + WaitStrategy waitStrategy, + Integer... ports + ) { + // apply WaitStrategy to container + return new GenericContainer<>(TestImages.ALPINE_IMAGE) + .withExposedPorts(ports) + .withCommand("sh", "-c", shellCommand) + .waitingFor(waitStrategy.withStartupTimeout(Duration.ofMillis(WAIT_TIMEOUT_MILLIS))); + } + + /** + * Expects that the WaitStrategy returns successfully after connection to a container with a listening port. + * + * @param shellCommand the shell command to execute + */ + protected void waitUntilReadyAndSucceed(String shellCommand) { + try (GenericContainer container = startContainerWithCommand(shellCommand)) { + waitUntilReadyAndSucceed(container); + } + } + + /** + * Expects that the WaitStrategy throws a {@link RetryCountExceededException} after unsuccessful connection + * to a container with a listening port. + * + * @param shellCommand the shell command to execute + */ + protected void waitUntilReadyAndTimeout(String shellCommand) { + try (GenericContainer container = startContainerWithCommand(shellCommand)) { + waitUntilReadyAndTimeout(container); + } + } + + /** + * Expects that the WaitStrategy throws a {@link RetryCountExceededException} after unsuccessful connection + * to a container with a listening port. + * + * @param container the container to start + */ + protected void waitUntilReadyAndTimeout(GenericContainer container) { + // start() blocks until successful or timeout + assertThat(catchThrowable(container::start)) + .as("an exception is thrown when timeout occurs (" + WAIT_TIMEOUT_MILLIS + "ms)") + .isInstanceOf(ContainerLaunchException.class); + } + + /** + * Expects that the WaitStrategy returns successfully after connection to a container with a listening port. + * + * @param container the container to start + */ + protected void waitUntilReadyAndSucceed(GenericContainer container) { + // start() blocks until successful or timeout + container.start(); + + assertThat(ready) + .as(String.format("Expected container to be ready after timeout of %sms", WAIT_TIMEOUT_MILLIS)) + .isTrue(); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/wait/strategy/HostPortWaitStrategyTest.java b/core/src/test/java/org/testcontainers/junit/wait/strategy/HostPortWaitStrategyTest.java new file mode 100644 index 00000000000..15b6aa74961 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/wait/strategy/HostPortWaitStrategyTest.java @@ -0,0 +1,46 @@ +package org.testcontainers.junit.wait.strategy; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.testcontainers.TestImages; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +import java.time.Duration; + +/** + * Test wait strategy with overloaded waitingFor methods. + * Other implementations of WaitStrategy are tested through backwards compatible wait strategy tests + */ +class HostPortWaitStrategyTest { + + @Nested + class DefaultHostPortWaitStrategyTest { + + public GenericContainer container = new GenericContainer<>(TestImages.ALPINE_IMAGE) + .withExposedPorts() + .withCommand("sh", "-c", "while true; do nc -lp 8080; done") + .withExposedPorts(8080) + .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(10))); + + @Test + void testWaiting() { + container.start(); + } + } + + @Nested + class ExplicitHostPortWaitStrategyTest { + + public GenericContainer container = new GenericContainer<>(TestImages.ALPINE_IMAGE) + .withExposedPorts() + .withCommand("sh", "-c", "while true; do nc -lp 8080; done") + .withExposedPorts(8080) + .waitingFor(Wait.forListeningPorts(8080).withStartupTimeout(Duration.ofSeconds(10))); + + @Test + void testWaiting() { + container.start(); + } + } +} diff --git a/core/src/test/java/org/testcontainers/junit/wait/strategy/HttpWaitStrategyTest.java b/core/src/test/java/org/testcontainers/junit/wait/strategy/HttpWaitStrategyTest.java new file mode 100644 index 00000000000..eb9169fd1ac --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/wait/strategy/HttpWaitStrategyTest.java @@ -0,0 +1,309 @@ +package org.testcontainers.junit.wait.strategy; + +import org.assertj.core.api.Assertions; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.rnorth.ducttape.RetryCountExceededException; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.images.builder.ImageFromDockerfile; + +import java.time.Duration; +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link HttpWaitStrategy}. + */ +class HttpWaitStrategyTest extends AbstractWaitStrategyTest { + + /** + * newline sequence indicating end of the HTTP header. + */ + private static final String NEWLINE = "\r\n"; + + private static final String GOOD_RESPONSE_BODY = "Good Response Body"; + + /** + * Expects that the WaitStrategy returns successfully after receiving an HTTP 200 response from the container. + */ + @Test + void testWaitUntilReadyWithSuccess() { + waitUntilReadyAndSucceed(createShellCommand("200 OK", GOOD_RESPONSE_BODY)); + } + + /** + * Ensures that HTTP requests made with the HttpWaitStrategy can be enriched with user defined headers, + * although the test web server does not depend on the header to response with a 200, by checking the + * logs we can ensure the HTTP request was correctly sent. + */ + @Test + void testWaitUntilReadyWithSuccessWithCustomHeaders() { + HashMap headers = new HashMap<>(); + headers.put("baz", "boo"); + try ( + GenericContainer container = startContainerWithCommand( + createShellCommand("200 OK", GOOD_RESPONSE_BODY), + createHttpWaitStrategy(ready).withHeader("foo", "bar").withHeaders(headers) + ) + ) { + waitUntilReadyAndSucceed(container); + + String logs = container.getLogs(); + + assertThat(logs).contains("foo: bar"); + assertThat(logs).contains("baz: boo"); + } + } + + /** + * Ensures that HTTPS requests made with the HttpWaitStrategy can skip the + * certificate validation chains (to support self-signed certificates for example). + */ + @Test + void testWaitUntilReadyWithTlsAndAllowUnsecure() { + try ( + GenericContainer container = startContainerWithCommand( + createHttpsShellCommand("200 OK", GOOD_RESPONSE_BODY, 8080), + createHttpWaitStrategy(ready).usingTls().allowInsecure() + ) + ) { + waitUntilReadyAndSucceed(container); + } + } + + /** + * Expects that the WaitStrategy returns successfully after receiving an HTTP 401 response from the container. + * This 401 response is checked with a lambda using {@link HttpWaitStrategy#forStatusCodeMatching(Predicate)} + */ + @Test + void testWaitUntilReadyWithUnauthorizedWithLambda() { + try ( + GenericContainer container = startContainerWithCommand( + createShellCommand("401 UNAUTHORIZED", GOOD_RESPONSE_BODY), + createHttpWaitStrategy(ready).forStatusCodeMatching(it -> it >= 200 && it < 300 || it == 401) + ) + ) { + waitUntilReadyAndSucceed(container); + } + } + + /** + * Expects that the WaitStrategy returns successfully after receiving an HTTP 401 response from the container. + * This 401 response is checked with many status codes using {@link HttpWaitStrategy#forStatusCode(int)} + */ + @Test + void testWaitUntilReadyWithManyStatusCodes() { + try ( + GenericContainer container = startContainerWithCommand( + createShellCommand("401 UNAUTHORIZED", GOOD_RESPONSE_BODY), + createHttpWaitStrategy(ready).forStatusCode(300).forStatusCode(401).forStatusCode(500) + ) + ) { + waitUntilReadyAndSucceed(container); + } + } + + /** + * Expects that the WaitStrategy returns successfully after receiving an HTTP 401 response from the container. + * This 401 response is checked with many status codes using {@link HttpWaitStrategy#forStatusCode(int)} + * and a lambda using {@link HttpWaitStrategy#forStatusCodeMatching(Predicate)} + */ + @Test + void testWaitUntilReadyWithManyStatusCodesAndLambda() { + try ( + GenericContainer container = startContainerWithCommand( + createShellCommand("401 UNAUTHORIZED", GOOD_RESPONSE_BODY), + createHttpWaitStrategy(ready) + .forStatusCode(300) + .forStatusCode(500) + .forStatusCodeMatching(it -> it == 401) + ) + ) { + waitUntilReadyAndSucceed(container); + } + } + + /** + * Expects that the WaitStrategy throws a {@link RetryCountExceededException} after not receiving any of the + * error code defined with {@link HttpWaitStrategy#forStatusCode(int)} + * and {@link HttpWaitStrategy#forStatusCodeMatching(Predicate)} + */ + @Test + void testWaitUntilReadyWithTimeoutAndWithManyStatusCodesAndLambda() { + try ( + GenericContainer container = startContainerWithCommand( + createShellCommand("401 UNAUTHORIZED", GOOD_RESPONSE_BODY), + createHttpWaitStrategy(ready).forStatusCode(300).forStatusCodeMatching(it -> it == 500) + ) + ) { + waitUntilReadyAndTimeout(container); + } + } + + /** + * Expects that the WaitStrategy throws a {@link RetryCountExceededException} after not receiving any of the + * error code defined with {@link HttpWaitStrategy#forStatusCode(int)} + * and {@link HttpWaitStrategy#forStatusCodeMatching(Predicate)}. Note that a 200 status code should not + * be considered as a successful return as not explicitly set. + * Test case for: https://github.com/testcontainers/testcontainers-java/issues/880 + */ + @Test + void testWaitUntilReadyWithTimeoutAndWithLambdaShouldNotMatchOk() { + try ( + GenericContainer container = startContainerWithCommand( + createShellCommand("200 OK", GOOD_RESPONSE_BODY), + createHttpWaitStrategy(ready).forStatusCodeMatching(it -> it >= 300) + ) + ) { + waitUntilReadyAndTimeout(container); + } + } + + /** + * Expects that the WaitStrategy throws a {@link RetryCountExceededException} after not receiving an HTTP 200 + * response from the container within the timeout period. + */ + @Test + void testWaitUntilReadyWithTimeout() { + waitUntilReadyAndTimeout(createShellCommand("400 Bad Request", GOOD_RESPONSE_BODY)); + } + + /** + * Expects that the WaitStrategy throws a {@link RetryCountExceededException} after not the expected response body + * from the container within the timeout period. + */ + @Test + void testWaitUntilReadyWithTimeoutAndBadResponseBody() { + waitUntilReadyAndTimeout(createShellCommand("200 OK", "Bad Response")); + } + + /** + * Expects the WaitStrategy probing the right port. + */ + @Test + void testWaitUntilReadyWithSpecificPort() { + try ( + GenericContainer container = startContainerWithCommand( + createShellCommand("200 OK", GOOD_RESPONSE_BODY, 9090), + createHttpWaitStrategy(ready).forPort(9090), + 7070, + 8080, + 9090 + ) + ) { + waitUntilReadyAndSucceed(container); + } + } + + @Test + void testWaitUntilReadyWithTimeoutCausedByReadTimeout() { + try ( + GenericContainer container = startContainerWithCommand( + createShellCommand("0 Connection Refused", GOOD_RESPONSE_BODY, 9090), + createHttpWaitStrategy(ready).forPort(9090).withReadTimeout(Duration.ofMillis(1)), + 9090 + ) + ) { + waitUntilReadyAndTimeout(container); + } + } + + /** + * Test to validate fix from GitHub Pull Request #5778, i.e. when the container startup fails (ContainerLaunchException) before timeout for some reason, we are able to see the root cause of the error in the stack trace, e.g. in this case, a TLS certificate validation error during the TLS handshake test, because we are using a NGINX docker image with self-signed certificate created with the image, that is obviously not trusted. + * The exceptions we should see in the stacktrace ('/' means 'caused by'): ContainerLaunchException / TimeoutException / RuntimeException / SSLHandshakeException / ValidatorException (in sun.* package so not accessible) / SunCertPathBuilderException (in sun.* package so not accessible). + */ + @Test + void testWaitUntilReadyWithTimeoutCausedBySslHandshakeError() { + try ( + GenericContainer container = new GenericContainer<>( + new ImageFromDockerfile() + .withFileFromClasspath("Dockerfile", "https-wait-strategy-dockerfile/Dockerfile") + .withFileFromClasspath("nginx-ssl.conf", "https-wait-strategy-dockerfile/nginx-ssl.conf") + ) + .withExposedPorts(8443) + .waitingFor( + createHttpWaitStrategy(ready) + .forPort(8443) + .usingTls() + .withStartupTimeout(Duration.ofMillis(WAIT_TIMEOUT_MILLIS)) + ) + ) { + Throwable throwable = Assertions.catchThrowable(container::start); + assertThat(throwable).hasStackTraceContaining("javax.net.ssl.SSLHandshakeException"); + } + } + + /** + * @param ready the AtomicBoolean on which to indicate success + * @return the WaitStrategy under test + */ + @NotNull + protected HttpWaitStrategy buildWaitStrategy(final AtomicBoolean ready) { + return createHttpWaitStrategy(ready).forResponsePredicate(s -> s.equals(GOOD_RESPONSE_BODY)); + } + + /** + * Create a HttpWaitStrategy instance with a waitUntilReady implementation + * + * @param ready Indicates that the WaitStrategy has completed waiting successfully. + * @return the HttpWaitStrategy instance + */ + private HttpWaitStrategy createHttpWaitStrategy(final AtomicBoolean ready) { + return new HttpWaitStrategy() { + @Override + protected void waitUntilReady() { + // blocks until ready or timeout occurs + super.waitUntilReady(); + ready.set(true); + } + }; + } + + private String createShellCommand(String header, String responseBody) { + return createShellCommand(header, responseBody, 8080); + } + + private String createShellCommand(String header, String responseBody, int port) { + int length = responseBody.getBytes().length; + return ( + "while true; do { echo -e \"HTTP/1.1 " + + header + + NEWLINE + + "Content-Type: text/html" + + NEWLINE + + "Content-Length: " + + length + + NEWLINE + + "\";" + + " echo \"" + + responseBody + + "\";} | nc -lp " + + port + + "; done" + ); + } + + private String createHttpsShellCommand(String header, String responseBody, int port) { + int length = responseBody.getBytes().length; + return ( + "apk add nmap-ncat; while true; do { echo -e \"HTTP/1.1 " + + header + + NEWLINE + + "Content-Type: text/html" + + NEWLINE + + "Content-Length: " + + length + + NEWLINE + + "\";" + + " echo \"" + + responseBody + + "\";} | ncat -lp " + + port + + " --ssl; done" + ); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/wait/strategy/LogMessageWaitStrategyTest.java b/core/src/test/java/org/testcontainers/junit/wait/strategy/LogMessageWaitStrategyTest.java new file mode 100644 index 00000000000..537254a60a1 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/wait/strategy/LogMessageWaitStrategyTest.java @@ -0,0 +1,66 @@ +package org.testcontainers.junit.wait.strategy; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Tests for {@link LogMessageWaitStrategy}. + */ +@ParameterizedClass(name = "{0}") +@MethodSource("parameters") +class LogMessageWaitStrategyTest extends AbstractWaitStrategyTest { + + private final String pattern; + + public static Object[] parameters() { + return new String[] { + ".*ready.*\\s", // previous recommended style (explicit line ending) + ".*ready!\\s", // explicit line ending without wildcard after expected text + ".*ready.*", // new style (line ending matched by wildcard) + }; + } + + public LogMessageWaitStrategyTest(String pattern) { + this.pattern = pattern; + } + + private static final String READY_MESSAGE = "I'm ready!"; + + @Test + void testWaitUntilReady_Success() { + waitUntilReadyAndSucceed( + "echo -e \"" + + READY_MESSAGE + + "\";" + + "echo -e \"foobar\";" + + "echo -e \"" + + READY_MESSAGE + + "\";" + + "sleep 300" + ); + } + + @Test + void testWaitUntilReady_Timeout() { + waitUntilReadyAndTimeout("echo -e \"" + READY_MESSAGE + "\";" + "echo -e \"foobar\";" + "sleep 300"); + } + + @NotNull + @Override + protected LogMessageWaitStrategy buildWaitStrategy(AtomicBoolean ready) { + return new LogMessageWaitStrategy() { + @Override + protected void waitUntilReady() { + super.waitUntilReady(); + ready.set(true); + } + } + .withRegEx(pattern) + .withTimes(2); + } +} diff --git a/core/src/test/java/org/testcontainers/junit/wait/strategy/ShellStrategyTest.java b/core/src/test/java/org/testcontainers/junit/wait/strategy/ShellStrategyTest.java new file mode 100644 index 00000000000..20b08a5f396 --- /dev/null +++ b/core/src/test/java/org/testcontainers/junit/wait/strategy/ShellStrategyTest.java @@ -0,0 +1,42 @@ +package org.testcontainers.junit.wait.strategy; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.wait.strategy.ShellStrategy; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Tests for {@link ShellStrategy}. + */ +class ShellStrategyTest extends AbstractWaitStrategyTest { + + private static final String LOCK_FILE = "/tmp/ready.lock"; + + @Test + void testWaitUntilReady_Success() { + waitUntilReadyAndSucceed(String.format("touch %s; sleep 300", LOCK_FILE)); + } + + @Test + void testWaitUntilReady_Timeout() { + waitUntilReadyAndTimeout("sleep 300"); + } + + @NotNull + @Override + protected ShellStrategy buildWaitStrategy(AtomicBoolean ready) { + return createShellStrategy(ready).withCommand(String.format("stat %s", LOCK_FILE)); + } + + @NotNull + private static ShellStrategy createShellStrategy(AtomicBoolean ready) { + return new ShellStrategy() { + @Override + protected void waitUntilReady() { + super.waitUntilReady(); + ready.set(true); + } + }; + } +} diff --git a/core/src/test/java/org/testcontainers/utility/AuthenticatedImagePullTest.java b/core/src/test/java/org/testcontainers/utility/AuthenticatedImagePullTest.java new file mode 100644 index 00000000000..171bd9a4381 --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/AuthenticatedImagePullTest.java @@ -0,0 +1,160 @@ +package org.testcontainers.utility; + +import com.github.dockerjava.api.model.AuthConfig; +import org.intellij.lang.annotations.Language; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.testcontainers.DockerRegistryContainer; +import org.testcontainers.TestImages; +import org.testcontainers.containers.ContainerState; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.images.builder.ImageFromDockerfile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.when; + +/** + * This test checks the integration between Testcontainers and an authenticated registry, but uses + * a mock instance of {@link RegistryAuthLocator} - the purpose of the test is solely to ensure that + * the auth locator is utilised, and that the credentials it provides flow through to the registry. + *

+ * {@link RegistryAuthLocatorTest} covers actual credential scenarios at a lower level, which are + * impractical to test end-to-end. + */ +public class AuthenticatedImagePullTest { + + /** + * Containerised docker image registry, with simple hardcoded credentials + */ + public static DockerRegistryContainer authenticatedRegistry = new DockerRegistryContainer( + new ImageFromDockerfile() + .withDockerfileFromBuilder(builder -> { + builder + .from(TestImages.DOCKER_REGISTRY_IMAGE.asCanonicalNameString()) + .run("htpasswd -Bbn testuser notasecret > /htpasswd") + .env("REGISTRY_AUTH", "htpasswd") + .env("REGISTRY_AUTH_HTPASSWD_PATH", "/htpasswd") + .env("REGISTRY_AUTH_HTPASSWD_REALM", "Test"); + }) + ); + + private static RegistryAuthLocator originalAuthLocatorSingleton; + + private final DockerImageName testImageName = authenticatedRegistry.createImage(); + + @BeforeAll + public static void beforeClass() throws Exception { + authenticatedRegistry.start(); + + originalAuthLocatorSingleton = RegistryAuthLocator.instance(); + + String testRegistryAddress = authenticatedRegistry.getEndpoint(); + + final AuthConfig authConfig = new AuthConfig() + .withUsername("testuser") + .withPassword("notasecret") + .withRegistryAddress("http://" + testRegistryAddress); + + // Replace the RegistryAuthLocator singleton with our mock, for the duration of this test + final RegistryAuthLocator mockAuthLocator = Mockito.mock(RegistryAuthLocator.class); + RegistryAuthLocator.setInstance(mockAuthLocator); + when( + mockAuthLocator.lookupAuthConfig( + argThat(argument -> testRegistryAddress.equals(argument.getRegistry())), + any() + ) + ) + .thenReturn(authConfig); + } + + @AfterAll + public static void tearDown() { + RegistryAuthLocator.setInstance(originalAuthLocatorSingleton); + } + + @Test + void testThatAuthLocatorIsUsedForContainerCreation() { + // actually start a container, which will require an authenticated pull + try ( + final GenericContainer container = new GenericContainer<>(testImageName) + .withCommand("/bin/sh", "-c", "sleep 10") + ) { + container.start(); + + assertThat(container.isRunning()).as("container started following an authenticated pull").isTrue(); + } + } + + @Test + void testThatAuthLocatorIsUsedForDockerfileBuild() throws IOException { + // Prepare a simple temporary Dockerfile which requires our custom private image + Path tempFile = getLocalTempFile(".Dockerfile"); + String dockerFileContent = "FROM " + testImageName.asCanonicalNameString(); + Files.write(tempFile, dockerFileContent.getBytes()); + + // Start a container built from a derived image, which will require an authenticated pull + try ( + final GenericContainer container = new GenericContainer<>( + new ImageFromDockerfile().withDockerfile(tempFile) + ) + .withCommand("/bin/sh", "-c", "sleep 10") + ) { + container.start(); + + assertThat(container.isRunning()).as("container started following an authenticated pull").isTrue(); + } + } + + @Test + void testThatAuthLocatorIsUsedForDockerComposePull() throws IOException { + // Prepare a simple temporary Docker Compose manifest which requires our custom private image + Path tempFile = getLocalTempFile(".docker-compose.yml"); + @Language("yaml") + String composeFileContent = + "version: '2.0'\n" + + "services:\n" + + " privateservice:\n" + + " command: /bin/sh -c 'sleep 60'\n" + + " image: " + + testImageName.asCanonicalNameString(); + Files.write(tempFile, composeFileContent.getBytes()); + + // Start the docker compose project, which will require an authenticated pull + try ( + final DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:1.29.2"), + tempFile.toFile() + ) + ) { + compose.start(); + + assertThat( + compose.getContainerByServiceName("privateservice_1").map(ContainerState::isRunning).orElse(false) + ) + .as("container started following an authenticated pull") + .isTrue(); + } + } + + private Path getLocalTempFile(String s) throws IOException { + Path projectRoot = Paths.get("."); + Path tempDirectory = Files.createTempDirectory(projectRoot, this.getClass().getSimpleName() + "-test-"); + Path relativeTempDirectory = projectRoot.relativize(tempDirectory); + Path tempFile = Files.createTempFile(relativeTempDirectory, "test", s); + + tempDirectory.toFile().deleteOnExit(); + tempFile.toFile().deleteOnExit(); + + return tempFile; + } +} diff --git a/core/src/test/java/org/testcontainers/utility/ClasspathScannerTest.java b/core/src/test/java/org/testcontainers/utility/ClasspathScannerTest.java new file mode 100644 index 00000000000..63812c998e3 --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/ClasspathScannerTest.java @@ -0,0 +1,95 @@ +package org.testcontainers.utility; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URL; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ClasspathScannerTest { + + private static URL FILE_A; + + private static URL FILE_B; + + private static URL JAR_A; + + private static URL JAR_B; + + private static URL FILE_C; + + @BeforeAll + public static void setUp() throws Exception { + FILE_A = new URL("file:///a/someName"); + FILE_B = new URL("file:///b/someName"); + FILE_C = new URL("file:///c/someName"); + JAR_A = new URL("jar:file:a!/someName"); + JAR_B = new URL("jar:file:b!/someName"); + } + + @Test + void realClassLoaderLookupOccurs() { + // look for a resource that we know exists only once + final List foundURLs = ClasspathScanner.scanFor("expectedClasspathFile.txt").collect(Collectors.toList()); + + assertThat(foundURLs).as("Exactly one resource was found").hasSize(1); + } + + @Test + void multipleResultsOnOneClassLoaderAreFound() throws IOException { + final ClassLoader firstMockClassLoader = mock(ClassLoader.class); + when(firstMockClassLoader.getResources(eq("someName"))) + .thenReturn(Collections.enumeration(Arrays.asList(FILE_A, FILE_B))); + + final List foundURLs = ClasspathScanner + .scanFor("someName", firstMockClassLoader) + .collect(Collectors.toList()); + assertThat(foundURLs).as("The expected URLs are found").containsExactly(FILE_A, FILE_B); + } + + @Test + void orderIsAlphabeticalForDeterminism() throws IOException { + final ClassLoader firstMockClassLoader = mock(ClassLoader.class); + when(firstMockClassLoader.getResources(eq("someName"))) + .thenReturn(Collections.enumeration(Arrays.asList(FILE_B, JAR_A, JAR_B, FILE_A))); + + final List foundURLs = ClasspathScanner + .scanFor("someName", firstMockClassLoader) + .collect(Collectors.toList()); + assertThat(foundURLs) + .as("The expected URLs are found in the expected order") + .containsExactly(FILE_A, FILE_B, JAR_A, JAR_B); + } + + @Test + void multipleClassLoadersAreQueried() throws IOException { + final ClassLoader firstMockClassLoader = mock(ClassLoader.class); + when(firstMockClassLoader.getResources(eq("someName"))) + .thenReturn(Collections.enumeration(Arrays.asList(FILE_A, FILE_B))); + final ClassLoader secondMockClassLoader = mock(ClassLoader.class); + when(secondMockClassLoader.getResources(eq("someName"))) + .thenReturn( + Collections.enumeration( + Arrays.asList( + FILE_B, // duplicate + FILE_C + ) + ) + ); + + final List foundURLs = ClasspathScanner + .scanFor("someName", firstMockClassLoader, secondMockClassLoader) + .collect(Collectors.toList()); + + assertThat(foundURLs).as("The expected URLs are found").containsExactly(FILE_A, FILE_B, FILE_C); + } +} diff --git a/core/src/test/java/org/testcontainers/utility/ComparableVersionTest.java b/core/src/test/java/org/testcontainers/utility/ComparableVersionTest.java new file mode 100644 index 00000000000..dadf3983926 --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/ComparableVersionTest.java @@ -0,0 +1,30 @@ +package org.testcontainers.utility; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class ComparableVersionTest { + + @ParameterizedTest(name = "Parsed version: {0}={1}") + @MethodSource("data") + void shouldParseVersions(String given, int[] expected) { + assertThat(ComparableVersion.parseVersion(given)).containsExactly(expected); + } + + public static Iterable data() { + return Arrays.asList( + new Object[][] { + { "1.2.3", new int[] { 1, 2, 3 } }, + { "", new int[0] }, + { "1", new int[] { 1 } }, + { "1.2.3.4.5.6.7", new int[] { 1, 2, 3, 4, 5, 6, 7 } }, + { "1.2-dev", new int[] { 1, 2 } }, + { "18.06.0-dev", new int[] { 18, 6 } }, + } + ); + } +} diff --git a/core/src/test/java/org/testcontainers/utility/DefaultImageNameSubstitutorTest.java b/core/src/test/java/org/testcontainers/utility/DefaultImageNameSubstitutorTest.java new file mode 100644 index 00000000000..143455d5b16 --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/DefaultImageNameSubstitutorTest.java @@ -0,0 +1,37 @@ +package org.testcontainers.utility; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; + +@ExtendWith(MockTestcontainersConfigurationExtension.class) +class DefaultImageNameSubstitutorTest { + + public static final DockerImageName ORIGINAL_IMAGE = DockerImageName.parse("foo"); + + public static final DockerImageName SUBSTITUTE_IMAGE = DockerImageName.parse("bar"); + + private ConfigurationFileImageNameSubstitutor underTest; + + @BeforeEach + public void setUp() { + underTest = new ConfigurationFileImageNameSubstitutor(TestcontainersConfiguration.getInstance()); + } + + @Test + void testConfigurationLookup() { + Mockito + .doReturn(SUBSTITUTE_IMAGE) + .when(TestcontainersConfiguration.getInstance()) + .getConfiguredSubstituteImage(eq(ORIGINAL_IMAGE)); + + final DockerImageName substitute = underTest.apply(ORIGINAL_IMAGE); + + assertThat(substitute).as("match is found").isEqualTo(SUBSTITUTE_IMAGE); + assertThat(substitute.isCompatibleWith(ORIGINAL_IMAGE)).as("compatibility is automatically set").isTrue(); + } +} diff --git a/core/src/test/java/org/testcontainers/utility/DirectoryTarResourceTest.java b/core/src/test/java/org/testcontainers/utility/DirectoryTarResourceTest.java new file mode 100644 index 00000000000..bfd3641adeb --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/DirectoryTarResourceTest.java @@ -0,0 +1,97 @@ +package org.testcontainers.utility; + +import org.assertj.core.api.Condition; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; +import org.testcontainers.images.builder.ImageFromDockerfile; + +import java.io.File; +import java.util.function.Predicate; + +import static org.assertj.core.api.Assertions.assertThat; + +class DirectoryTarResourceTest { + + @Test + void simpleRecursiveFileTest() { + // 'src' is expected to be the project base directory, so all source code/resources should be copied in + File directory = new File("src"); + + GenericContainer container = new GenericContainer( + new ImageFromDockerfile() + .withDockerfileFromBuilder(builder -> { + builder + .from("alpine:3.17") + .copy("/tmp/foo", "/foo") + .cmd("cat /foo/test/resources/test-recursive-file.txt") + .build(); + }) + .withFileFromFile("/tmp/foo", directory) + ) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()); + + container.start(); + + final String results = container.getLogs(); + + assertThat(results) + .as("The container has a file that was copied in via a recursive copy") + .contains("Used for DirectoryTarResourceTest"); + } + + @Test + void simpleRecursiveFileWithPermissionTest() { + try ( + GenericContainer container = new GenericContainer( + new ImageFromDockerfile() + .withDockerfileFromBuilder(builder -> { + builder + .from("alpine:3.17") // + .copy("/tmp/foo", "/foo") + .cmd("ls", "-al", "/") + .build(); + }) + .withFileFromFile("/tmp/foo", new File("/mappable-resource/test-resource.txt"), 0754) + ) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + ) { + container.start(); + String listing = container.getLogs(); + + Predicate condition = s -> s.contains("-rwxr-xr--") && s.contains("foo"); + assertThat(listing.split("\\n")) + .as("Listing shows that file is copied with mode requested.") + .haveAtLeastOne(new Condition<>(condition, "File not found in listing")); + } + } + + @Test + void simpleRecursiveClasspathResourceTest() { + // This test combines the copying of classpath resources from JAR files with the recursive TAR approach, to allow JARed classpath resources to be copied in to an image + + try ( + GenericContainer container = new GenericContainer( + new ImageFromDockerfile() + .withDockerfileFromBuilder(builder -> { + builder + .from("alpine:3.17") // + .copy("/tmp/foo", "/foo") + .cmd("ls -lRt /foo") + .build(); + }) + .withFileFromClasspath("/tmp/foo", "/recursive/dir") + ) // here we use /org/junit as a directory that really should exist on the classpath + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + ) { + container.start(); + + final String results = container.getLogs(); + + // ExternalResource.class is known to exist in a subdirectory of /org/junit so should be successfully copied in + assertThat(results) + .as("The container has a file that was copied in via a recursive copy from a JAR resource") + .contains("content.txt"); + } + } +} diff --git a/core/src/test/java/org/testcontainers/utility/DockerImageNameCompatibilityTest.java b/core/src/test/java/org/testcontainers/utility/DockerImageNameCompatibilityTest.java new file mode 100644 index 00000000000..ce0759fec47 --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/DockerImageNameCompatibilityTest.java @@ -0,0 +1,136 @@ +package org.testcontainers.utility; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DockerImageNameCompatibilityTest { + + @Test + void testPlainImage() { + DockerImageName subject = DockerImageName.parse("foo"); + + assertThat(subject.isCompatibleWith(DockerImageName.parse("bar"))).as("image name foo != bar").isFalse(); + } + + @Test + void testNoTagTreatedAsWildcard() { + final DockerImageName subject = DockerImageName.parse("foo:4.5.6"); + /* + foo:1.2.3 != foo:4.5.6 + foo:1.2.3 ~= foo + + The test is effectively making sure that 'no tag' is treated as a wildcard + */ + assertThat(subject.isCompatibleWith(DockerImageName.parse("foo:1.2.3"))).as("foo:4.5.6 != foo:1.2.3").isFalse(); + assertThat(subject.isCompatibleWith(DockerImageName.parse("foo"))).as("foo:4.5.6 ~= foo").isTrue(); + } + + @Test + void testImageWithAutomaticCompatibilityForFullPath() { + DockerImageName subject = DockerImageName.parse("repo/foo:1.2.3"); + + assertThat(subject.isCompatibleWith(DockerImageName.parse("repo/foo"))) + .as("repo/foo:1.2.3 ~= repo/foo") + .isTrue(); + } + + @Test + void testImageWithClaimedCompatibility() { + DockerImageName subject = DockerImageName.parse("foo").asCompatibleSubstituteFor("bar"); + + assertThat(subject.isCompatibleWith(DockerImageName.parse("bar"))).as("foo(bar) ~= bar").isTrue(); + assertThat(subject.isCompatibleWith(DockerImageName.parse("fizz"))).as("foo(bar) != fizz").isFalse(); + } + + @Test + void testImageWithClaimedCompatibilityAndVersion() { + DockerImageName subject = DockerImageName.parse("foo:1.2.3").asCompatibleSubstituteFor("bar"); + + assertThat(subject.isCompatibleWith(DockerImageName.parse("bar"))).as("foo:1.2.3(bar) ~= bar").isTrue(); + } + + @Test + void testImageWithClaimedCompatibilityForFullPath() { + DockerImageName subject = DockerImageName.parse("foo").asCompatibleSubstituteFor("registry/repo/bar"); + + assertThat(subject.isCompatibleWith(DockerImageName.parse("registry/repo/bar"))) + .as("foo(registry/repo/bar) ~= registry/repo/bar") + .isTrue(); + assertThat(subject.isCompatibleWith(DockerImageName.parse("repo/bar"))) + .as("foo(registry/repo/bar) != repo/bar") + .isFalse(); + assertThat(subject.isCompatibleWith(DockerImageName.parse("bar"))) + .as("foo(registry/repo/bar) != bar") + .isFalse(); + } + + @Test + void testImageWithClaimedCompatibilityForVersion() { + DockerImageName subject = DockerImageName.parse("foo").asCompatibleSubstituteFor("bar:1.2.3"); + + assertThat(subject.isCompatibleWith(DockerImageName.parse("bar"))).as("foo(bar:1.2.3) ~= bar").isTrue(); + assertThat(subject.isCompatibleWith(DockerImageName.parse("bar:1.2.3"))) + .as("foo(bar:1.2.3) ~= bar:1.2.3") + .isTrue(); + assertThat(subject.isCompatibleWith(DockerImageName.parse("bar:0.0.1"))) + .as("foo(bar:1.2.3) != bar:0.0.1") + .isFalse(); + assertThat(subject.isCompatibleWith(DockerImageName.parse("bar:2.0.0"))) + .as("foo(bar:1.2.3) != bar:2.0.0") + .isFalse(); + assertThat(subject.isCompatibleWith(DockerImageName.parse("bar:1.2.4"))) + .as("foo(bar:1.2.3) != bar:1.2.4") + .isFalse(); + } + + @Test + void testDigestOnlyImageIsCompatible() { + DockerImageName subject = DockerImageName.parse("postgres@sha256:1234abcd1234abcd1234abcd1234abcd"); + + assertThat(subject.isCompatibleWith(DockerImageName.parse("postgres"))) + .as("postgres@sha256:... ~= postgres") + .isTrue(); + } + + @Test + void testTagAndDigestImageIsCompatible() { + DockerImageName subject = DockerImageName.parse("postgres:16.8@sha256:1234abcd1234abcd1234abcd1234abcd"); + + assertThat(subject.isCompatibleWith(DockerImageName.parse("postgres"))) + .as("postgres:16.8@sha256:... ~= postgres") + .isTrue(); + } + + @Test + void testTagAndDigestImageWithRegistryIsCompatible() { + DockerImageName subject = DockerImageName.parse( + "registry.foo.com/repo:tag@sha256:1234abcd1234abcd1234abcd1234abcd" + ); + + assertThat(subject.isCompatibleWith(DockerImageName.parse("registry.foo.com/repo"))) + .as("registry.foo.com/repo:tag@sha256:... ~= registry.foo.com/repo") + .isTrue(); + } + + @Test + void testAssertMethodAcceptsCompatible() { + DockerImageName subject = DockerImageName.parse("foo").asCompatibleSubstituteFor("bar"); + subject.assertCompatibleWith(DockerImageName.parse("bar")); + } + + @Test + void testAssertMethodAcceptsCompatibleLibraryPrefix() { + DockerImageName subject = DockerImageName.parse("library/foo"); + subject.assertCompatibleWith(DockerImageName.parse("foo")); + } + + @Test + void testAssertMethodRejectsIncompatible() { + DockerImageName subject = DockerImageName.parse("foo"); + assertThatThrownBy(() -> subject.assertCompatibleWith(DockerImageName.parse("bar"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to verify that image 'foo' is a compatible substitute for 'bar'"); + } +} diff --git a/core/src/test/java/org/testcontainers/utility/DockerImageNameTest.java b/core/src/test/java/org/testcontainers/utility/DockerImageNameTest.java index aea13f69068..5b42322d678 100644 --- a/core/src/test/java/org/testcontainers/utility/DockerImageNameTest.java +++ b/core/src/test/java/org/testcontainers/utility/DockerImageNameTest.java @@ -1,18 +1,221 @@ package org.testcontainers.utility; -import org.junit.Test; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; -public class DockerImageNameTest { +import java.util.stream.Stream; - @Test - public void validNames() { - DockerImageName.validate("myname:latest"); // no repo - DockerImageName.validate("repo/my-name:1.0"); // no repo - DockerImageName.validate("repo.foo.com:1234/my-name:1.0"); // no repo +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DockerImageNameTest { + + @Nested + class ValidNames { + + public static String[] getNames() { + return new String[] { + "myname:latest", + "repo/my-name:1.0", + "registry.foo.com:1234/my-name:1.0", + "registry.foo.com/my-name:1.0", + "registry.foo.com:1234/repo_here/my-name:1.0", + "registry.foo.com:1234/repo-here/my-name@sha256:1234abcd1234abcd1234abcd1234abcd", + "registry.foo.com:1234/my-name@sha256:1234abcd1234abcd1234abcd1234abcd", + "myname:latest@sha256:1234abcd1234abcd1234abcd1234abcd", + "registry.foo.com:1234/repo-here/my-name:1.0@sha256:1234abcd1234abcd1234abcd1234abcd", + "1.2.3.4/my-name:1.0", + "1.2.3.4:1234/my-name:1.0", + "1.2.3.4/repo-here/my-name:1.0", + "1.2.3.4:1234/repo-here/my-name:1.0", + }; + } + + @ParameterizedTest + @MethodSource("getNames") + void testValidNameAccepted(String imageName) { + DockerImageName.parse(imageName).assertValid(); + } + } + + @Nested + class InvalidNames { + + public static String[] getNames() { + return new String[] { + ":latest", + "/myname:latest", + "/myname@sha256:latest", + "/myname@sha256:gggggggggggggggggggggggggggggggg", + "repo:notaport/myname:latest", + }; + } + + @ParameterizedTest + @MethodSource("getNames") + void testInvalidNameRejected(String imageName) { + assertThatThrownBy(() -> DockerImageName.parse(imageName).assertValid()) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + class Parsing { + + public static Stream getNames() { + return Stream.of( + Arguments.of("", "", "myname", ":", null), + Arguments.of("", "", "myname", ":", "latest"), + Arguments.of("", "", "repo/myname", ":", null), + Arguments.of("", "", "repo/myname", ":", "latest"), + Arguments.of("registry.foo.com:1234", "/", "my-name", ":", null), + Arguments.of("registry.foo.com:1234", "/", "my-name", ":", "1.0"), + Arguments.of("registry.foo.com", "/", "my-name", ":", "1.0"), + Arguments.of("registry.foo.com:1234", "/", "repo_here/my-name", ":", null), + Arguments.of("registry.foo.com:1234", "/", "repo_here/my-name", ":", "1.0"), + Arguments.of("1.2.3.4:1234", "/", "repo_here/my-name", ":", null), + Arguments.of("1.2.3.4:1234", "/", "repo_here/my-name", ":", "1.0"), + Arguments.of("1.2.3.4:1234", "/", "my-name", ":", null), + Arguments.of("1.2.3.4:1234", "/", "my-name", ":", "1.0"), + Arguments.of("", "", "myname", "@", "sha256:1234abcd1234abcd1234abcd1234abcd"), + Arguments.of("", "", "repo/myname", "@", "sha256:1234abcd1234abcd1234abcd1234abcd"), + Arguments.of( + "registry.foo.com:1234", + "/", + "repo-here/my-name", + "@", + "sha256:1234abcd1234abcd1234abcd1234abcd" + ), + Arguments.of("registry.foo.com:1234", "/", "my-name", "@", "sha256:1234abcd1234abcd1234abcd1234abcd"), + Arguments.of("1.2.3.4", "/", "my-name", "@", "sha256:1234abcd1234abcd1234abcd1234abcd"), + Arguments.of("1.2.3.4:1234", "/", "my-name", "@", "sha256:1234abcd1234abcd1234abcd1234abcd"), + Arguments.of("1.2.3.4", "/", "my-name", "@", "sha256:1234abcd1234abcd1234abcd1234abcd"), + Arguments.of("1.2.3.4:1234", "/", "my-name", "@", "sha256:1234abcd1234abcd1234abcd1234abcd") + ); + } + + @ParameterizedTest + @MethodSource("getNames") + void testParsing( + String registry, + String registrySeparator, + String repo, + String versionSeparator, + String version + ) { + final String unversionedPart = registry + registrySeparator + repo; + + String combined; + String canonicalName; + if (version != null) { + combined = unversionedPart + versionSeparator + version; + canonicalName = unversionedPart + versionSeparator + version; + } else { + combined = unversionedPart; + canonicalName = unversionedPart + ":latest"; + } + + final DockerImageName imageName = DockerImageName.parse(combined); + assertThat(imageName.getRegistry()).as(combined + " has registry address: " + registry).isEqualTo(registry); + assertThat(imageName.getUnversionedPart()) + .as(combined + " has unversioned part: " + unversionedPart) + .isEqualTo(unversionedPart); + if (version != null) { + assertThat(imageName.getVersionPart()) + .as(combined + " has version part: " + version) + .isEqualTo(version); + } else { + assertThat(imageName.getVersionPart()) + .as(combined + " has automatic 'latest' version specified") + .isEqualTo("latest"); + } + assertThat(imageName.asCanonicalNameString()) + .as(combined + " has canonical name: " + canonicalName) + .isEqualTo(canonicalName); + + if (version != null) { + final DockerImageName imageNameFromSecondaryConstructor = new DockerImageName(unversionedPart, version); + assertThat(imageNameFromSecondaryConstructor.getRegistry()) + .as(combined + " has registry address: " + registry) + .isEqualTo(registry); + assertThat(imageNameFromSecondaryConstructor.getUnversionedPart()) + .as(combined + " has unversioned part: " + unversionedPart) + .isEqualTo(unversionedPart); + assertThat(imageNameFromSecondaryConstructor.getVersionPart()) + .as(combined + " has version part: " + version) + .isEqualTo(version); + } + } } - @Test(expected = IllegalArgumentException.class) - public void missingTag() { - DockerImageName.validate("myname"); + @Nested + class TagAndDigestParsing { + + @Test + void testTagAndDigestStripsTagFromRepository() { + DockerImageName imageName = DockerImageName.parse("myname:latest@sha256:1234abcd1234abcd1234abcd1234abcd"); + + assertThat(imageName.getRegistry()).isEqualTo(""); + assertThat(imageName.getUnversionedPart()).isEqualTo("myname"); + assertThat(imageName.getVersionPart()).isEqualTo("latest"); + assertThat(imageName.getDigest()).isEqualTo("sha256:1234abcd1234abcd1234abcd1234abcd"); + assertThat(imageName.asCanonicalNameString()) + .isEqualTo("myname:latest@sha256:1234abcd1234abcd1234abcd1234abcd"); + } + + @Test + void testTagAndDigestWithRepoPath() { + DockerImageName imageName = DockerImageName.parse( + "repo/myname:1.0@sha256:1234abcd1234abcd1234abcd1234abcd" + ); + + assertThat(imageName.getRegistry()).isEqualTo(""); + assertThat(imageName.getUnversionedPart()).isEqualTo("repo/myname"); + assertThat(imageName.getVersionPart()).isEqualTo("1.0"); + assertThat(imageName.getDigest()).isEqualTo("sha256:1234abcd1234abcd1234abcd1234abcd"); + assertThat(imageName.asCanonicalNameString()) + .isEqualTo("repo/myname:1.0@sha256:1234abcd1234abcd1234abcd1234abcd"); + } + + @Test + void testTagAndDigestWithRegistry() { + DockerImageName imageName = DockerImageName.parse( + "registry.foo.com:1234/repo-here/my-name:1.0@sha256:1234abcd1234abcd1234abcd1234abcd" + ); + + assertThat(imageName.getRegistry()).isEqualTo("registry.foo.com:1234"); + assertThat(imageName.getUnversionedPart()).isEqualTo("registry.foo.com:1234/repo-here/my-name"); + assertThat(imageName.getVersionPart()).isEqualTo("1.0"); + assertThat(imageName.getDigest()).isEqualTo("sha256:1234abcd1234abcd1234abcd1234abcd"); + assertThat(imageName.asCanonicalNameString()) + .isEqualTo("registry.foo.com:1234/repo-here/my-name:1.0@sha256:1234abcd1234abcd1234abcd1234abcd"); + } + + @Test + void testMalformedTagWithExtraColonIsRejected() { + assertThatThrownBy(() -> { + DockerImageName.parse("repo/image:tag:extra@sha256:1234abcd1234abcd1234abcd1234abcd").assertValid(); + }) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testDifferentTagsSameDigestAreNotEqual() { + DockerImageName a = DockerImageName.parse("repo/image:1.0@sha256:1234abcd1234abcd1234abcd1234abcd"); + DockerImageName b = DockerImageName.parse("repo/image:latest@sha256:1234abcd1234abcd1234abcd1234abcd"); + + assertThat(a).isNotEqualTo(b); + } + + @Test + void testDigestOnlyHasNoTag() { + DockerImageName imageName = DockerImageName.parse("myname@sha256:1234abcd1234abcd1234abcd1234abcd"); + + assertThat(imageName.getVersionPart()).isEqualTo("sha256:1234abcd1234abcd1234abcd1234abcd"); + assertThat(imageName.getDigest()).isEqualTo("sha256:1234abcd1234abcd1234abcd1234abcd"); + } } -} \ No newline at end of file +} diff --git a/core/src/test/java/org/testcontainers/utility/DockerLoggerFactoryTest.java b/core/src/test/java/org/testcontainers/utility/DockerLoggerFactoryTest.java new file mode 100644 index 00000000000..cf3352902ec --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/DockerLoggerFactoryTest.java @@ -0,0 +1,31 @@ +package org.testcontainers.utility; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DockerLoggerFactoryTest { + + private static final Logger LOGGER = (Logger) DockerLoggerFactory.getLogger("dockerImageName"); + + @Test + void debugIsNotSwallowedForContainerLogs() { + ListAppender listAppender = new ListAppender<>(); + listAppender.start(); + LOGGER.addAppender(listAppender); + + LOGGER.debug("some text"); + + assertThat(listAppender.list).withFailMessage("Log message has been swallowed").hasSize(1); + + ILoggingEvent event = listAppender.list.get(0); + + assertThat(event.getFormattedMessage()).isEqualTo("some text"); + assertThat(event.getLevel()).isEqualTo(Level.DEBUG); + assertThat(event.getLoggerName()).startsWith("tc"); + } +} diff --git a/core/src/test/java/org/testcontainers/utility/DockerStatusTest.java b/core/src/test/java/org/testcontainers/utility/DockerStatusTest.java index 4b63719f928..543cbdfeba1 100644 --- a/core/src/test/java/org/testcontainers/utility/DockerStatusTest.java +++ b/core/src/test/java/org/testcontainers/utility/DockerStatusTest.java @@ -1,79 +1,97 @@ package org.testcontainers.utility; import com.github.dockerjava.api.command.InspectContainerResponse; -import org.junit.Test; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mockito; import java.time.Duration; import java.time.Instant; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.stream.Stream; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; -/** - * - */ -public class DockerStatusTest { +@ParameterizedClass +@MethodSource("parameters") +class DockerStatusTest { - private static Instant now = Instant.now(); + private DateTimeFormatter dateTimeFormatter; - private static InspectContainerResponse.ContainerState running = - buildState(true, false, buildTimestamp(now.minusMillis(30)), DockerStatus.DOCKER_TIMESTAMP_ZERO); + private static final Instant now = Instant.now(); - private static InspectContainerResponse.ContainerState runningVariant = - buildState(true, false, buildTimestamp(now.minusMillis(30)), ""); + private final InspectContainerResponse.ContainerState running; - private static InspectContainerResponse.ContainerState shortRunning = - buildState(true, false, buildTimestamp(now.minusMillis(10)), DockerStatus.DOCKER_TIMESTAMP_ZERO); + private final InspectContainerResponse.ContainerState runningVariant; - private static InspectContainerResponse.ContainerState created = - buildState(false, false, DockerStatus.DOCKER_TIMESTAMP_ZERO, DockerStatus.DOCKER_TIMESTAMP_ZERO); + private final InspectContainerResponse.ContainerState shortRunning; + + private final InspectContainerResponse.ContainerState created; // a container in the "created" state is not running, and has both startedAt and finishedAt empty. - private static InspectContainerResponse.ContainerState createdVariant = - buildState(false, false, null, null); + private final InspectContainerResponse.ContainerState createdVariant; + + private final InspectContainerResponse.ContainerState exited; + + private final InspectContainerResponse.ContainerState paused; - private static InspectContainerResponse.ContainerState exited = - buildState(false, false, buildTimestamp(now.minusMillis(100)), buildTimestamp(now.minusMillis(90))); + private static final Duration minimumDuration = Duration.ofMillis(20); - private static InspectContainerResponse.ContainerState paused = - buildState(false, true, buildTimestamp(now.minusMillis(100)), DockerStatus.DOCKER_TIMESTAMP_ZERO); + public DockerStatusTest(DateTimeFormatter dateTimeFormatter) { + this.dateTimeFormatter = dateTimeFormatter; + running = buildState(true, false, buildTimestamp(now.minusMillis(30)), DockerStatus.DOCKER_TIMESTAMP_ZERO); + runningVariant = buildState(true, false, buildTimestamp(now.minusMillis(30)), ""); + shortRunning = buildState(true, false, buildTimestamp(now.minusMillis(10)), DockerStatus.DOCKER_TIMESTAMP_ZERO); + created = buildState(false, false, DockerStatus.DOCKER_TIMESTAMP_ZERO, DockerStatus.DOCKER_TIMESTAMP_ZERO); + createdVariant = buildState(false, false, null, null); + exited = buildState(false, false, buildTimestamp(now.minusMillis(100)), buildTimestamp(now.minusMillis(90))); + paused = buildState(false, true, buildTimestamp(now.minusMillis(100)), DockerStatus.DOCKER_TIMESTAMP_ZERO); + } - private static Duration minimumDuration = Duration.ofMillis(20); + public static Stream parameters() { + return Stream.of( + DateTimeFormatter.ISO_INSTANT, + DateTimeFormatter.ISO_OFFSET_DATE_TIME.withZone(ZoneId.of("America/New_York")) + ); + } @Test - public void testRunning() throws Exception { - assertTrue(DockerStatus.isContainerRunning(running, minimumDuration, now)); - assertTrue(DockerStatus.isContainerRunning(runningVariant, minimumDuration, now)); - assertFalse(DockerStatus.isContainerRunning(shortRunning, minimumDuration, now)); - assertFalse(DockerStatus.isContainerRunning(created, minimumDuration, now)); - assertFalse(DockerStatus.isContainerRunning(createdVariant, minimumDuration, now)); - assertFalse(DockerStatus.isContainerRunning(exited, minimumDuration, now)); - assertFalse(DockerStatus.isContainerRunning(paused, minimumDuration, now)); + void testRunning() { + assertThat(DockerStatus.isContainerRunning(running, minimumDuration, now)).isTrue(); + assertThat(DockerStatus.isContainerRunning(runningVariant, minimumDuration, now)).isTrue(); + assertThat(DockerStatus.isContainerRunning(shortRunning, minimumDuration, now)).isFalse(); + assertThat(DockerStatus.isContainerRunning(created, minimumDuration, now)).isFalse(); + assertThat(DockerStatus.isContainerRunning(createdVariant, minimumDuration, now)).isFalse(); + assertThat(DockerStatus.isContainerRunning(exited, minimumDuration, now)).isFalse(); + assertThat(DockerStatus.isContainerRunning(paused, minimumDuration, now)).isFalse(); } @Test - public void testStopped() throws Exception { - assertFalse(DockerStatus.isContainerStopped(running)); - assertFalse(DockerStatus.isContainerStopped(runningVariant)); - assertFalse(DockerStatus.isContainerStopped(shortRunning)); - assertFalse(DockerStatus.isContainerStopped(created)); - assertFalse(DockerStatus.isContainerStopped(createdVariant)); - assertTrue(DockerStatus.isContainerStopped(exited)); - assertFalse(DockerStatus.isContainerStopped(paused)); + void testStopped() { + assertThat(DockerStatus.isContainerStopped(running)).isFalse(); + assertThat(DockerStatus.isContainerStopped(runningVariant)).isFalse(); + assertThat(DockerStatus.isContainerStopped(shortRunning)).isFalse(); + assertThat(DockerStatus.isContainerStopped(created)).isFalse(); + assertThat(DockerStatus.isContainerStopped(createdVariant)).isFalse(); + assertThat(DockerStatus.isContainerStopped(exited)).isTrue(); + assertThat(DockerStatus.isContainerStopped(paused)).isFalse(); } - private static String buildTimestamp(Instant instant) { - return DateTimeFormatter.ISO_INSTANT.format(instant); + private String buildTimestamp(Instant instant) { + return dateTimeFormatter.format(instant); } // ContainerState is a non-static inner class, with private member variables, in a different package. // It's simpler to mock it that to try to create one. - private static InspectContainerResponse.ContainerState buildState(boolean running, boolean paused, - String startedAt, String finishedAt) { - + private static InspectContainerResponse.ContainerState buildState( + boolean running, + boolean paused, + String startedAt, + String finishedAt + ) { InspectContainerResponse.ContainerState state = Mockito.mock(InspectContainerResponse.ContainerState.class); when(state.getRunning()).thenReturn(running); when(state.getPaused()).thenReturn(paused); diff --git a/core/src/test/java/org/testcontainers/utility/FakeImagePullPolicy.java b/core/src/test/java/org/testcontainers/utility/FakeImagePullPolicy.java new file mode 100644 index 00000000000..5ecead64d4e --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/FakeImagePullPolicy.java @@ -0,0 +1,12 @@ +package org.testcontainers.utility; + +import org.testcontainers.images.AbstractImagePullPolicy; +import org.testcontainers.images.ImageData; + +public class FakeImagePullPolicy extends AbstractImagePullPolicy { + + @Override + protected boolean shouldPullCached(DockerImageName imageName, ImageData localImageData) { + return false; + } +} diff --git a/core/src/test/java/org/testcontainers/utility/FakeImageSubstitutor.java b/core/src/test/java/org/testcontainers/utility/FakeImageSubstitutor.java new file mode 100644 index 00000000000..50681ddc729 --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/FakeImageSubstitutor.java @@ -0,0 +1,14 @@ +package org.testcontainers.utility; + +public class FakeImageSubstitutor extends ImageNameSubstitutor { + + @Override + public DockerImageName apply(final DockerImageName original) { + return DockerImageName.parse("transformed-" + original.asCanonicalNameString()); + } + + @Override + protected String getDescription() { + return "test implementation"; + } +} diff --git a/core/src/test/java/org/testcontainers/utility/FilterRegistryTest.java b/core/src/test/java/org/testcontainers/utility/FilterRegistryTest.java new file mode 100644 index 00000000000..af71e49664e --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/FilterRegistryTest.java @@ -0,0 +1,72 @@ +package org.testcontainers.utility; + +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.ResourceReaper.FilterRegistry; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.AbstractMap.SimpleEntry; +import java.util.Arrays; +import java.util.List; +import java.util.Map.Entry; + +import static org.assertj.core.api.Assertions.assertThat; + +class FilterRegistryTest { + + private static final List> FILTERS = Arrays.asList( + new SimpleEntry<>("key1!", "value2?"), + new SimpleEntry<>("key2#", "value2%") + ); + + private static final String URL_ENCODED_FILTERS = "key1%21=value2%3F&key2%23=value2%25"; + + private static final byte[] ACKNOWLEDGEMENT = FilterRegistry.ACKNOWLEDGMENT.getBytes(); + + private static final byte[] NO_ACKNOWLEDGEMENT = "".getBytes(); + + private static final String NEW_LINE = "\n"; + + @Test + void registerReturnsTrueIfAcknowledgementIsReadFromInputStream() throws IOException { + FilterRegistry registry = new FilterRegistry(inputStream(ACKNOWLEDGEMENT), anyOutputStream()); + + boolean successful = registry.register(FILTERS); + + assertThat(successful).isTrue(); + } + + @Test + void registerReturnsFalseIfNoAcknowledgementIsReadFromInputStream() throws IOException { + FilterRegistry registry = new FilterRegistry(inputStream(NO_ACKNOWLEDGEMENT), anyOutputStream()); + + boolean successful = registry.register(FILTERS); + + assertThat(successful).isFalse(); + } + + @Test + void registerWritesUrlEncodedFiltersAndNewlineToOutputStream() throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + FilterRegistry registry = new FilterRegistry(anyInputStream(), outputStream); + + registry.register(FILTERS); + + assertThat(new String(outputStream.toByteArray())).isEqualTo(URL_ENCODED_FILTERS + NEW_LINE); + } + + private static InputStream inputStream(byte[] bytes) { + return new ByteArrayInputStream(bytes); + } + + private static InputStream anyInputStream() { + return inputStream(ACKNOWLEDGEMENT); + } + + private static OutputStream anyOutputStream() { + return new ByteArrayOutputStream(); + } +} diff --git a/core/src/test/java/org/testcontainers/utility/ImageNameSubstitutorTest.java b/core/src/test/java/org/testcontainers/utility/ImageNameSubstitutorTest.java new file mode 100644 index 00000000000..e6a0c294c77 --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/ImageNameSubstitutorTest.java @@ -0,0 +1,137 @@ +package org.testcontainers.utility; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; +import org.testcontainers.containers.GenericContainer; + +import java.io.FileWriter; +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; + +@ExtendWith(MockTestcontainersConfigurationExtension.class) +class ImageNameSubstitutorTest { + + @TempDir + public Path tempFolder; + + private ImageNameSubstitutor originalInstance; + + private ImageNameSubstitutor originalDefaultImplementation; + + @BeforeEach + public void setUp() throws Exception { + originalInstance = ImageNameSubstitutor.instance; + originalDefaultImplementation = ImageNameSubstitutor.defaultImplementation; + ImageNameSubstitutor.instance = null; + ImageNameSubstitutor.defaultImplementation = Mockito.mock(ImageNameSubstitutor.class); + + Mockito + .doReturn(DockerImageName.parse("substituted-image")) + .when(ImageNameSubstitutor.defaultImplementation) + .apply(eq(DockerImageName.parse("original"))); + Mockito.doReturn("default implementation").when(ImageNameSubstitutor.defaultImplementation).getDescription(); + } + + @AfterEach + public void tearDown() throws Exception { + ImageNameSubstitutor.instance = originalInstance; + ImageNameSubstitutor.defaultImplementation = originalDefaultImplementation; + } + + @Test + void simpleConfigurationTest() { + Mockito + .doReturn(FakeImageSubstitutor.class.getCanonicalName()) + .when(TestcontainersConfiguration.getInstance()) + .getImageSubstitutorClassName(); + + final ImageNameSubstitutor imageNameSubstitutor = ImageNameSubstitutor.instance(); + + DockerImageName result = imageNameSubstitutor.apply(DockerImageName.parse("original")); + assertThat(result.asCanonicalNameString()) + .as("the image has been substituted by default then configured implementations") + .isEqualTo("transformed-substituted-image:latest"); + } + + @Test + void testWorksWithoutConfiguredImplementation() { + Mockito.doReturn(null).when(TestcontainersConfiguration.getInstance()).getImageSubstitutorClassName(); + + final ImageNameSubstitutor imageNameSubstitutor = ImageNameSubstitutor.instance(); + + DockerImageName result = imageNameSubstitutor.apply(DockerImageName.parse("original")); + assertThat(result.asCanonicalNameString()) + .as("the image has been substituted by default then configured implementations") + .isEqualTo("substituted-image:latest"); + } + + @Test + void testImageNameSubstitutorToString() { + Mockito + .doReturn(FakeImageSubstitutor.class.getCanonicalName()) + .when(TestcontainersConfiguration.getInstance()) + .getImageSubstitutorClassName(); + + try (GenericContainer container = new GenericContainer<>(DockerImageName.parse("original"))) { + assertThatThrownBy(container::start) + .hasMessageContaining( + "imageNameSubstitutor=Chained substitutor of 'default implementation' and then 'test implementation'" + ); + } + } + + @Test + void testImageNameSubstitutorFromServiceLoader() throws IOException { + Path tempDir = this.tempFolder.resolve("image-name-substitutor-test"); + Path metaInfDir = Paths.get(tempDir.toString(), "META-INF", "services"); + Files.createDirectories(metaInfDir); + + createClassFile(tempDir, "org/testcontainers/utility/ImageNameSubstitutor.class", ImageNameSubstitutor.class); + createClassFile(tempDir, "org/testcontainers/utility/FakeImageSubstitutor.class", FakeImageSubstitutor.class); + + // Create service provider configuration file + createServiceProviderFile( + metaInfDir, + "org.testcontainers.utility.ImageNameSubstitutor", + "org.testcontainers.utility.FakeImageSubstitutor" + ); + + URL[] urls = { tempDir.toUri().toURL() }; + URLClassLoader classLoader = new URLClassLoader(urls, ImageNameSubstitutorTest.class.getClassLoader()); + + final ImageNameSubstitutor imageNameSubstitutor = ImageNameSubstitutor.instance(classLoader); + + DockerImageName result = imageNameSubstitutor.apply(DockerImageName.parse("original")); + assertThat(result.asCanonicalNameString()) + .as("the image has been substituted by default then configured implementations") + .isEqualTo("transformed-substituted-image:latest"); + } + + private void createClassFile(Path tempDir, String classFilePath, Class clazz) throws IOException { + Path classFile = Paths.get(tempDir.toString(), classFilePath); + Files.createDirectories(classFile.getParent()); + Files.copy(clazz.getResourceAsStream("/" + classFilePath), classFile); + } + + private void createServiceProviderFile(Path metaInfDir, String serviceInterface, String... implementations) + throws IOException { + Path serviceFile = Paths.get(metaInfDir.toString(), serviceInterface); + try (FileWriter writer = new FileWriter(serviceFile.toFile())) { + for (String impl : implementations) { + writer.write(impl + "\n"); + } + } + } +} diff --git a/core/src/test/java/org/testcontainers/utility/LazyFutureTest.java b/core/src/test/java/org/testcontainers/utility/LazyFutureTest.java index 81805106806..afc78076b43 100644 --- a/core/src/test/java/org/testcontainers/utility/LazyFutureTest.java +++ b/core/src/test/java/org/testcontainers/utility/LazyFutureTest.java @@ -2,21 +2,27 @@ import com.google.common.util.concurrent.Futures; import lombok.SneakyThrows; -import org.junit.Test; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import java.util.Collections; import java.util.List; -import java.util.concurrent.*; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import java.util.stream.IntStream; -import static java.util.stream.Collectors.toList; -import static org.rnorth.visibleassertions.VisibleAssertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; -public class LazyFutureTest { +class LazyFutureTest { @Test - public void testLazyness() throws Exception { + void testLaziness() throws Exception { AtomicInteger counter = new AtomicInteger(); Future lazyFuture = new LazyFuture() { @@ -26,16 +32,17 @@ protected Integer resolve() { } }; - assertEquals("No resolve() invocations before get()", 0, counter.get()); - assertEquals("get() call returns proper result", 1, lazyFuture.get()); - assertEquals("resolve() was called only once after single get() call", 1, counter.get()); + assertThat(counter).as("No resolve() invocations before get()").hasValue(0); + assertThat(lazyFuture.get()).as("get() call returns proper result").isEqualTo(1); + assertThat(counter).as("resolve() was called only once after single get() call").hasValue(1); counter.incrementAndGet(); - assertEquals("result of resolve() must be cached", 1, lazyFuture.get()); + assertThat(lazyFuture.get()).as("result of resolve() must be cached").isEqualTo(1); } - @Test(timeout = 5_000) - public void timeoutWorks() throws Exception { + @Test + @Timeout(5) + void timeoutWorks() { Future lazyFuture = new LazyFuture() { @Override @SneakyThrows(InterruptedException.class) @@ -45,12 +52,14 @@ protected Void resolve() { } }; - assertThrows("Should timeout", TimeoutException.class, () -> lazyFuture.get(10, TimeUnit.MILLISECONDS)); - pass("timeout works"); + assertThat(catchThrowable(() -> lazyFuture.get(10, TimeUnit.MILLISECONDS))) + .as("Should timeout") + .isInstanceOf(TimeoutException.class); } - @Test(timeout = 5_000) - public void testThreadSafety() throws Exception { + @Test + @Timeout(5) + void testThreadSafety() throws Exception { final int numOfThreads = 3; CountDownLatch latch = new CountDownLatch(numOfThreads); AtomicInteger counter = new AtomicInteger(); @@ -64,15 +73,21 @@ protected Integer resolve() { } }; - Future> task = new ForkJoinPool(numOfThreads).submit(() -> { - return IntStream.rangeClosed(1, numOfThreads).parallel().mapToObj(i -> Futures.getUnchecked(lazyFuture)).collect(toList()); - }); + Future> task = new ForkJoinPool(numOfThreads) + .submit(() -> { + return IntStream + .rangeClosed(1, numOfThreads) + .parallel() + .mapToObj(i -> Futures.getUnchecked(lazyFuture)) + .collect(Collectors.toList()); + }); while (latch.getCount() > 0) { latch.countDown(); } - assertEquals("All threads receives the same result", Collections.nCopies(numOfThreads, 1), task.get()); + assertThat(task.get()) + .as("All threads receives the same result") + .isEqualTo(Collections.nCopies(numOfThreads, 1)); } - -} \ No newline at end of file +} diff --git a/core/src/test/java/org/testcontainers/utility/LicenseAcceptanceTest.java b/core/src/test/java/org/testcontainers/utility/LicenseAcceptanceTest.java new file mode 100644 index 00000000000..c0ca63c06bc --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/LicenseAcceptanceTest.java @@ -0,0 +1,20 @@ +package org.testcontainers.utility; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LicenseAcceptanceTest { + + @Test + void testForExistingNames() { + LicenseAcceptance.assertLicenseAccepted("a"); + LicenseAcceptance.assertLicenseAccepted("b"); + } + + @Test + void testForMissingNames() { + assertThatThrownBy(() -> LicenseAcceptance.assertLicenseAccepted("c")) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/core/src/test/java/org/testcontainers/utility/MockTestcontainersConfigurationExtension.java b/core/src/test/java/org/testcontainers/utility/MockTestcontainersConfigurationExtension.java new file mode 100644 index 00000000000..a94e6b7772e --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/MockTestcontainersConfigurationExtension.java @@ -0,0 +1,39 @@ +package org.testcontainers.utility; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.mockito.Mockito; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * This {@link org.junit.jupiter.api.extension.Extension} applies a spy on {@link TestcontainersConfiguration} + * for testing features that depend on the global configuration. + */ +public class MockTestcontainersConfigurationExtension implements BeforeEachCallback, AfterEachCallback { + + private static final ExtensionContext.Namespace NS = ExtensionContext.Namespace.create( + MockTestcontainersConfigurationExtension.class + ); + + static AtomicReference REF = TestcontainersConfiguration.getInstanceField(); + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + TestcontainersConfiguration previous = REF.get(); + if (previous == null) { + previous = TestcontainersConfiguration.getInstance(); + } + REF.set(Mockito.spy(previous)); + context.getStore(NS).put(context.getUniqueId(), previous); + } + + @Override + public void afterEach(ExtensionContext context) throws Exception { + TestcontainersConfiguration previous = context + .getStore(NS) + .remove(context.getUniqueId(), TestcontainersConfiguration.class); + REF.set(previous); + } +} diff --git a/core/src/test/java/org/testcontainers/utility/MountableFileTest.java b/core/src/test/java/org/testcontainers/utility/MountableFileTest.java index e42d92f07c1..72db429c263 100644 --- a/core/src/test/java/org/testcontainers/utility/MountableFileTest.java +++ b/core/src/test/java/org/testcontainers/utility/MountableFileTest.java @@ -1,41 +1,60 @@ package org.testcontainers.utility; +import lombok.Cleanup; +import org.apache.commons.compress.archivers.ArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; import org.jetbrains.annotations.NotNull; -import org.junit.Test; +import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.function.Consumer; -import static org.rnorth.visibleassertions.VisibleAssertions.assertFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; -public class MountableFileTest { +class MountableFileTest { + + private static final int TEST_FILE_MODE = 0532; + + private static final int BASE_FILE_MODE = 0100000; + + private static final int BASE_DIR_MODE = 0040000; @Test - public void forClasspathResource() throws Exception { + void forClasspathResource() throws Exception { final MountableFile mountableFile = MountableFile.forClasspathResource("mappable-resource/test-resource.txt"); performChecks(mountableFile); } @Test - public void forClasspathResourceWithAbsolutePath() throws Exception { + void forClasspathResourceWithAbsolutePath() throws Exception { final MountableFile mountableFile = MountableFile.forClasspathResource("/mappable-resource/test-resource.txt"); performChecks(mountableFile); } @Test - public void forClasspathResourceFromJar() throws Exception { - final MountableFile mountableFile = MountableFile.forClasspathResource("docker-java.properties"); + void forClasspathResourceFromJar() throws Exception { + final MountableFile mountableFile = MountableFile.forClasspathResource("META-INF/dummy_unique_name.txt"); + + performChecks(mountableFile); + } + + @Test + void forClasspathResourceFromJarWithAbsolutePath() throws Exception { + final MountableFile mountableFile = MountableFile.forClasspathResource("/META-INF/dummy_unique_name.txt"); performChecks(mountableFile); } @Test - public void forHostPath() throws Exception { + void forHostPath() throws Exception { final Path file = createTempFile("somepath"); final MountableFile mountableFile = MountableFile.forHostPath(file.toString()); @@ -43,18 +62,85 @@ public void forHostPath() throws Exception { } @Test - public void forHostPathWithSpaces() throws Exception { + void forHostPathWithSpaces() throws Exception { final Path file = createTempFile("some path"); final MountableFile mountableFile = MountableFile.forHostPath(file.toString()); performChecks(mountableFile); - assertTrue("The resolved path contains the original space", mountableFile.getResolvedPath().contains(" "));assertFalse("The resolved path does not contain an escaped space", mountableFile.getResolvedPath().contains("\\ ")); + assertThat(mountableFile.getResolvedPath()).as("The resolved path contains the original space").contains(" "); + assertThat(mountableFile.getResolvedPath()) + .as("The resolved path does not contain an escaped space") + .doesNotContain("\\ "); + } + + @Test + void forHostPathWithPlus() throws Exception { + final Path file = createTempFile("some+path"); + final MountableFile mountableFile = MountableFile.forHostPath(file.toString()); + + performChecks(mountableFile); + + assertThat(mountableFile.getResolvedPath()).as("The resolved path contains the original space").contains("+"); + assertThat(mountableFile.getResolvedPath()) + .as("The resolved path does not contain an escaped space") + .doesNotContain(" "); + } + + @Test + void forClasspathResourceWithPermission() throws Exception { + final MountableFile mountableFile = MountableFile.forClasspathResource( + "mappable-resource/test-resource.txt", + TEST_FILE_MODE + ); + + performChecks(mountableFile); + assertThat(mountableFile.getFileMode()).as("Valid file mode.").isEqualTo(BASE_FILE_MODE | TEST_FILE_MODE); + } + + @Test + void forHostFilePathWithPermission() throws Exception { + final Path file = createTempFile("somepath"); + final MountableFile mountableFile = MountableFile.forHostPath(file.toString(), TEST_FILE_MODE); + performChecks(mountableFile); + assertThat(mountableFile.getFileMode()).as("Valid file mode.").isEqualTo(BASE_FILE_MODE | TEST_FILE_MODE); + } + + @Test + void forHostDirPathWithPermission() throws Exception { + final Path dir = createTempDir(); + final MountableFile mountableFile = MountableFile.forHostPath(dir.toString(), TEST_FILE_MODE); + performChecks(mountableFile); + assertThat(mountableFile.getFileMode()).as("Valid dir mode.").isEqualTo(BASE_DIR_MODE | TEST_FILE_MODE); + } + + @Test + void noTrailingSlashesInTarEntryNames() throws Exception { + final MountableFile mountableFile = MountableFile.forClasspathResource("mappable-resource/test-resource.txt"); + + @Cleanup + final TarArchiveInputStream tais = intoTarArchive(taos -> { + mountableFile.transferTo(taos, "/some/path.txt"); + mountableFile.transferTo(taos, "/path.txt"); + mountableFile.transferTo(taos, "path.txt"); + }); + + ArchiveEntry entry; + while ((entry = tais.getNextEntry()) != null) { + assertThat(entry.getName()).as("no entries should have a trailing slash").doesNotEndWith("/"); + } } - /* - * - */ + private TarArchiveInputStream intoTarArchive(Consumer consumer) throws IOException { + @Cleanup + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + @Cleanup + final TarArchiveOutputStream taos = new TarArchiveOutputStream(baos); + consumer.accept(taos); + taos.close(); + + return new TarArchiveInputStream(new ByteArrayInputStream(baos.toByteArray())); + } @SuppressWarnings("ResultOfMethodCallIgnored") @NotNull @@ -68,10 +154,16 @@ private Path createTempFile(final String name) throws IOException { return file; } + @NotNull + private Path createTempDir() throws IOException { + return Files.createTempDirectory("testcontainers"); + } + private void performChecks(final MountableFile mountableFile) { final String mountablePath = mountableFile.getResolvedPath(); - assertTrue("The resolved path can be found", new File(mountablePath).exists()); - assertFalse("The resolved path does not contain any URL escaping", mountablePath.contains("%20")); + assertThat(new File(mountablePath)).as("The filesystem path '" + mountablePath + "' can be found").exists(); + assertThat(mountablePath) + .as("The filesystem path '" + mountablePath + "' does not contain any URL escaping") + .doesNotContain("%20"); } - -} \ No newline at end of file +} diff --git a/core/src/test/java/org/testcontainers/utility/PrefixingImageNameSubstitutorTest.java b/core/src/test/java/org/testcontainers/utility/PrefixingImageNameSubstitutorTest.java new file mode 100644 index 00000000000..6eb6c62905c --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/PrefixingImageNameSubstitutorTest.java @@ -0,0 +1,129 @@ +package org.testcontainers.utility; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class PrefixingImageNameSubstitutorTest { + + private TestcontainersConfiguration mockConfiguration; + + private PrefixingImageNameSubstitutor underTest; + + @BeforeEach + public void setUp() { + mockConfiguration = mock(TestcontainersConfiguration.class); + underTest = new PrefixingImageNameSubstitutor(mockConfiguration); + } + + @Test + void testHappyPath() { + when(mockConfiguration.getEnvVarOrProperty(eq(PrefixingImageNameSubstitutor.PREFIX_PROPERTY_KEY), any())) + .thenReturn("someregistry.com/our-mirror/"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("some/image:tag")); + + assertThat(result.asCanonicalNameString()) + .as("The prefix is applied") + .isEqualTo("someregistry.com/our-mirror/some/image:tag"); + } + + @Test + void hubIoRegistryIsNotChanged() { + when(mockConfiguration.getEnvVarOrProperty(eq(PrefixingImageNameSubstitutor.PREFIX_PROPERTY_KEY), any())) + .thenReturn("someregistry.com/our-mirror/"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("docker.io/some/image:tag")); + + assertThat(result.asCanonicalNameString()).as("The prefix is applied").isEqualTo("docker.io/some/image:tag"); + } + + @Test + void hubComRegistryIsNotChanged() { + when(mockConfiguration.getEnvVarOrProperty(eq(PrefixingImageNameSubstitutor.PREFIX_PROPERTY_KEY), any())) + .thenReturn("someregistry.com/our-mirror/"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("registry.hub.docker.com/some/image:tag")); + + assertThat(result.asCanonicalNameString()) + .as("The prefix is applied") + .isEqualTo("registry.hub.docker.com/some/image:tag"); + } + + @Test + void thirdPartyRegistriesNotAffected() { + when(mockConfiguration.getEnvVarOrProperty(eq(PrefixingImageNameSubstitutor.PREFIX_PROPERTY_KEY), any())) + .thenReturn("someregistry.com/our-mirror/"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("gcr.io/something/image:tag")); + + assertThat(result.asCanonicalNameString()) + .as("The prefix is not applied if a third party registry is used") + .isEqualTo("gcr.io/something/image:tag"); + } + + @Test + void testNoDoublePrefixing() { + when(mockConfiguration.getEnvVarOrProperty(eq(PrefixingImageNameSubstitutor.PREFIX_PROPERTY_KEY), any())) + .thenReturn("someregistry.com/our-mirror/"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("someregistry.com/some/image:tag")); + + assertThat(result.asCanonicalNameString()) + .as("The prefix is not applied if already present") + .isEqualTo("someregistry.com/some/image:tag"); + } + + @Test + void testHandlesEmptyValue() { + when(mockConfiguration.getEnvVarOrProperty(eq(PrefixingImageNameSubstitutor.PREFIX_PROPERTY_KEY), any())) + .thenReturn(""); + + final DockerImageName result = underTest.apply(DockerImageName.parse("some/image:tag")); + + assertThat(result.asCanonicalNameString()) + .as("The prefix is not applied if the env var is not set") + .isEqualTo("some/image:tag"); + } + + @Test + void testHandlesRegistryOnlyWithTrailingSlash() { + when(mockConfiguration.getEnvVarOrProperty(eq(PrefixingImageNameSubstitutor.PREFIX_PROPERTY_KEY), any())) + .thenReturn("someregistry.com/"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("some/image:tag")); + + assertThat(result.asCanonicalNameString()) + .as("The prefix is applied") + .isEqualTo("someregistry.com/some/image:tag"); + } + + @Test + void testCombinesLiterallyForRegistryOnlyWithoutTrailingSlash() { + when(mockConfiguration.getEnvVarOrProperty(eq(PrefixingImageNameSubstitutor.PREFIX_PROPERTY_KEY), any())) + .thenReturn("someregistry.com"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("some/image:tag")); + + assertThat(result.asCanonicalNameString()) + .as("The prefix is applied") + .isEqualTo("someregistry.comsome/image:tag"); + } + + @Test + void testCombinesLiterallyForBothPartsWithoutTrailingSlash() { + when(mockConfiguration.getEnvVarOrProperty(eq(PrefixingImageNameSubstitutor.PREFIX_PROPERTY_KEY), any())) + .thenReturn("someregistry.com/our-mirror"); + + final DockerImageName result = underTest.apply(DockerImageName.parse("some/image:tag")); + + assertThat(result.asCanonicalNameString()) + .as("The prefix is applied") + .isEqualTo("someregistry.com/our-mirrorsome/image:tag"); + } +} diff --git a/core/src/test/java/org/testcontainers/utility/RegistryAuthLocatorTest.java b/core/src/test/java/org/testcontainers/utility/RegistryAuthLocatorTest.java new file mode 100644 index 00000000000..6988cfd86a8 --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/RegistryAuthLocatorTest.java @@ -0,0 +1,373 @@ +package org.testcontainers.utility; + +import com.github.dockerjava.api.model.AuthConfig; +import com.google.common.io.Resources; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.SystemUtils; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class RegistryAuthLocatorTest { + + @Test + void lookupAuthConfigWithoutCredentials() throws URISyntaxException, IOException { + final RegistryAuthLocator authLocator = createTestAuthLocator("config-empty.json"); + + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("unauthenticated.registry.org/org/repo"), + new AuthConfig() + ); + + assertThat(authConfig.getRegistryAddress()) + .as("Default docker registry URL is set on auth config") + .isEqualTo("https://index.docker.io/v1/"); + assertThat(authConfig.getUsername()).as("No username is set").isNull(); + assertThat(authConfig.getPassword()).as("No password is set").isNull(); + } + + @Test + void lookupAuthConfigWithBasicAuthCredentials() throws URISyntaxException, IOException { + final RegistryAuthLocator authLocator = createTestAuthLocator("config-basic-auth.json"); + + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("registry.example.com/org/repo"), + new AuthConfig() + ); + + assertThat(authConfig.getRegistryAddress()) + .as("Default docker registry URL is set on auth config") + .isEqualTo("https://registry.example.com"); + assertThat(authConfig.getUsername()).as("Username is set").isEqualTo("user"); + assertThat(authConfig.getPassword()).as("Password is set").isEqualTo("pass"); + } + + @Test + void lookupAuthConfigWithJsonKeyCredentials() throws URISyntaxException, IOException { + final RegistryAuthLocator authLocator = createTestAuthLocator("config-with-json-key.json"); + + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("registry.example.com/org/repo"), + new AuthConfig() + ); + + assertThat(authConfig.getRegistryAddress()) + .as("Default docker registry URL is set on auth config") + .isEqualTo("https://registry.example.com"); + assertThat(authConfig.getUsername()).as("Username is set").isEqualTo("_json_key"); + assertThat(authConfig.getPassword()).as("Password is set").isNotNull(); + } + + @Test + void lookupAuthConfigWithJsonKeyCredentialsPartialMatchShouldGiveNoResult() throws URISyntaxException, IOException { + // contains entry for registry.example.com + final RegistryAuthLocator authLocator = createTestAuthLocator("config-with-json-key.json"); + + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("registry.example.co/org/repo"), // partial match of registry name + new AuthConfig() + ); + + assertThat(authConfig.getUsername()).as("auth config username").isNull(); + assertThat(authConfig.getPassword()).as("auth config password").isNull(); + } + + @Test + void lookupAuthConfigUsingStore() throws URISyntaxException, IOException { + final RegistryAuthLocator authLocator = createTestAuthLocator("config-with-store.json"); + + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("registry.example.com/org/repo"), + new AuthConfig() + ); + + assertThat(authConfig.getRegistryAddress()) + .as("Correct server URL is obtained from a credential store") + .isEqualTo("url"); + assertThat(authConfig.getUsername()) + .as("Correct username is obtained from a credential store") + .isEqualTo("username"); + assertThat(authConfig.getPassword()) + .as("Correct secret is obtained from a credential store") + .isEqualTo("secret"); + } + + @Test + void lookupAuthConfigUsingHelper() throws URISyntaxException, IOException { + final RegistryAuthLocator authLocator = createTestAuthLocator("config-with-helper.json"); + + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("registry.example.com/org/repo"), + new AuthConfig() + ); + + assertThat(authConfig.getRegistryAddress()) + .as("Correct server URL is obtained from a credential store") + .isEqualTo("url"); + assertThat(authConfig.getUsername()) + .as("Correct username is obtained from a credential store") + .isEqualTo("username"); + assertThat(authConfig.getPassword()) + .as("Correct secret is obtained from a credential store") + .isEqualTo("secret"); + } + + @Test + void lookupAuthConfigUsingHelperWithToken() throws URISyntaxException, IOException { + final RegistryAuthLocator authLocator = createTestAuthLocator("config-with-helper-using-token.json"); + + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("registrytoken.example.com/org/repo"), + new AuthConfig() + ); + + assertThat(authConfig.getRegistryAddress()) + .as("Correct server URL is obtained from a credential store") + .isEqualTo("url"); + assertThat(authConfig.getIdentitytoken()) + .as("Correct identitytoken is obtained from a credential store") + .isEqualTo("secret"); + } + + @Test + void lookupUsingHelperEmptyAuth() throws URISyntaxException, IOException { + final RegistryAuthLocator authLocator = createTestAuthLocator("config-empty-auth-with-helper.json"); + + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("registry.example.com/org/repo"), + new AuthConfig() + ); + + assertThat(authConfig.getRegistryAddress()) + .as("Correct server URL is obtained from a credential store") + .isEqualTo("url"); + assertThat(authConfig.getUsername()) + .as("Correct username is obtained from a credential store") + .isEqualTo("username"); + assertThat(authConfig.getPassword()) + .as("Correct secret is obtained from a credential store") + .isEqualTo("secret"); + } + + @Test + void lookupNonEmptyAuthWithHelper() throws URISyntaxException, IOException { + final RegistryAuthLocator authLocator = createTestAuthLocator("config-existing-auth-with-helper.json"); + + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("registry.example.com/org/repo"), + new AuthConfig() + ); + + assertThat(authConfig.getRegistryAddress()) + .as("Correct server URL is obtained from a credential helper") + .isEqualTo("url"); + assertThat(authConfig.getUsername()) + .as("Correct username is obtained from a credential helper") + .isEqualTo("username"); + assertThat(authConfig.getPassword()) + .as("Correct password is obtained from a credential helper") + .isEqualTo("secret"); + } + + @Test + void lookupAuthConfigUsingHelperNoServerUrl() throws URISyntaxException, IOException { + final RegistryAuthLocator authLocator = createTestAuthLocator("config-with-helper-no-server-url.json"); + + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("registrynoserverurl.example.com/org/repo"), + new AuthConfig() + ); + + assertThat(authConfig.getRegistryAddress()) + .as("Fallback (registry) server URL is used") + .isEqualTo("registrynoserverurl.example.com"); + assertThat(authConfig.getUsername()) + .as("Correct username is obtained from a credential store") + .isEqualTo("username"); + assertThat(authConfig.getPassword()) + .as("Correct secret is obtained from a credential store") + .isEqualTo("secret"); + } + + @Test + void lookupAuthConfigUsingHelperNoServerUrlWithToken() throws URISyntaxException, IOException { + final RegistryAuthLocator authLocator = createTestAuthLocator( + "config-with-helper-no-server-url-using-token.json" + ); + + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("registrynoserverurltoken.example.com/org/repo"), + new AuthConfig() + ); + + assertThat(authConfig.getRegistryAddress()) + .as("Fallback (registry) server URL is used") + .isEqualTo("registrynoserverurltoken.example.com"); + assertThat(authConfig.getIdentitytoken()) + .as("Correct identitytoken is obtained from a credential store") + .isEqualTo("secret"); + } + + @Test + void lookupAuthConfigWithCredentialsNotFound() throws URISyntaxException, IOException { + Map notFoundMessagesReference = new HashMap<>(); + final RegistryAuthLocator authLocator = createTestAuthLocator( + "config-with-store.json", + notFoundMessagesReference + ); + + DockerImageName dockerImageName = DockerImageName.parse("registry2.example.com/org/repo"); + final AuthConfig authConfig = authLocator.lookupAuthConfig(dockerImageName, new AuthConfig()); + + assertThat(authConfig.getUsername()) + .as("No username should have been obtained from a credential store") + .isNull(); + assertThat(authConfig.getPassword()).as("No secret should have been obtained from a credential store").isNull(); + assertThat(notFoundMessagesReference.size()) + .as("Should have one 'credentials not found' message discovered") + .isEqualTo(1); + + String discoveredMessage = notFoundMessagesReference.values().iterator().next(); + + assertThat(discoveredMessage) + .as("Not correct message discovered") + .isEqualTo("Fake credentials not found on credentials store 'registry2.example.com'"); + } + + @Test + void lookupAuthConfigWithCredStoreEmpty() throws URISyntaxException, IOException { + final RegistryAuthLocator authLocator = createTestAuthLocator("config-with-store-empty.json"); + + DockerImageName dockerImageName = DockerImageName.parse("registry2.example.com/org/repo"); + final AuthConfig authConfig = authLocator.lookupAuthConfig(dockerImageName, new AuthConfig()); + + assertThat(authConfig.getAuth()).as("CredStore field will be ignored, because value is blank").isNull(); + } + + @Test + void lookupAuthConfigFromEnvVarWithCredStoreEmpty() throws URISyntaxException, IOException { + final RegistryAuthLocator authLocator = createTestAuthLocator(null, "config-with-store-empty.json"); + + DockerImageName dockerImageName = DockerImageName.parse("registry2.example.com/org/repo"); + final AuthConfig authConfig = authLocator.lookupAuthConfig(dockerImageName, new AuthConfig()); + + assertThat(authConfig.getAuth()).as("CredStore field will be ignored, because value is blank").isNull(); + } + + @Test + void lookupAuthConfigWithoutConfigFile() throws URISyntaxException, IOException { + final RegistryAuthLocator authLocator = createTestAuthLocator(null); + + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("unauthenticated.registry.org/org/repo"), + new AuthConfig() + ); + + assertThat(authConfig.getRegistryAddress()) + .as("Default docker registry URL is set on auth config") + .isEqualTo("https://index.docker.io/v1/"); + assertThat(authConfig.getUsername()).as("No username is set").isNull(); + assertThat(authConfig.getPassword()).as("No password is set").isNull(); + } + + @Test + void lookupAuthConfigRespectsCheckOrderPreference() throws URISyntaxException, IOException { + final RegistryAuthLocator authLocator = createTestAuthLocator("config-empty.json", "config-basic-auth.json"); + + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("registry.example.com/org/repo"), + new AuthConfig() + ); + + assertThat(authConfig.getRegistryAddress()) + .as("Default docker registry URL is set on auth config") + .isEqualTo("https://registry.example.com"); + assertThat(authConfig.getUsername()).as("Username is set").isEqualTo("user"); + assertThat(authConfig.getPassword()).as("Password is set").isEqualTo("pass"); + } + + @Test + void lookupAuthConfigFromEnvironmentVariable() throws URISyntaxException, IOException { + final RegistryAuthLocator authLocator = createTestAuthLocator(null, "config-basic-auth.json"); + + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("registry.example.com/org/repo"), + new AuthConfig() + ); + + assertThat(authConfig.getRegistryAddress()) + .as("Default docker registry URL is set on auth config") + .isEqualTo("https://registry.example.com"); + assertThat(authConfig.getUsername()).as("Username is set").isEqualTo("user"); + assertThat(authConfig.getPassword()).as("Password is set").isEqualTo("pass"); + } + + @NotNull + private RegistryAuthLocator createTestAuthLocator(String configName, String envConfigName) + throws URISyntaxException, IOException { + return createTestAuthLocator(configName, envConfigName, new HashMap<>()); + } + + @NotNull + private RegistryAuthLocator createTestAuthLocator(String configName) throws URISyntaxException, IOException { + return createTestAuthLocator(configName, null, new HashMap<>()); + } + + @NotNull + private RegistryAuthLocator createTestAuthLocator(String configName, Map notFoundMessagesReference) + throws URISyntaxException, IOException { + return createTestAuthLocator(configName, null, notFoundMessagesReference); + } + + @NotNull + private RegistryAuthLocator createTestAuthLocator( + String configName, + String envConfigName, + Map notFoundMessagesReference + ) throws URISyntaxException, IOException { + File configFile = null; + String commandPathPrefix = ""; + String commandExtension = ""; + String configEnv = null; + + if (configName != null) { + configFile = new File(Resources.getResource("auth-config/" + configName).toURI()); + + commandPathPrefix = configFile.getParentFile().getAbsolutePath() + "/"; + } else { + configFile = new File(new URI("file:///not-exists.json")); + } + + if (envConfigName != null) { + final File envConfigFile = new File(Resources.getResource("auth-config/" + envConfigName).toURI()); + configEnv = FileUtils.readFileToString(envConfigFile, StandardCharsets.UTF_8); + + commandPathPrefix = envConfigFile.getParentFile().getAbsolutePath() + "/"; + } + + if (SystemUtils.IS_OS_WINDOWS) { + commandPathPrefix += "win/"; + + // need to provide executable extension otherwise won't run it + // with real docker wincredential exe there is no problem + commandExtension = ".bat"; + } + + return new RegistryAuthLocator( + configFile, + configEnv, + commandPathPrefix, + commandExtension, + notFoundMessagesReference + ); + } +} diff --git a/core/src/test/java/org/testcontainers/utility/ResourceReaperTest.java b/core/src/test/java/org/testcontainers/utility/ResourceReaperTest.java new file mode 100644 index 00000000000..7b0c463d056 --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/ResourceReaperTest.java @@ -0,0 +1,113 @@ +package org.testcontainers.utility; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.dockerjava.api.DockerClient; +import lombok.SneakyThrows; +import org.awaitility.Awaitility; +import org.awaitility.core.ConditionFactory; +import org.junit.jupiter.api.Test; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; +import org.zeroturnaround.exec.ProcessExecutor; +import org.zeroturnaround.exec.ProcessResult; + +import java.io.File; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class ResourceReaperTest { + + @Test + void shouldCleanupWithRyuk() { + Map labels = runProcess(processExecutor -> {}); + + assertCleanup(labels); + } + + @Test + void shouldCleanupWithJVM() { + Map labels = runProcess(processExecutor -> { + processExecutor.environment("TESTCONTAINERS_RYUK_DISABLED", "true"); + }); + + assertCleanup(labels); + } + + private void assertCleanup(Map labels) { + DockerClient client = DockerClientFactory.instance().client(); + ConditionFactory awaitFactory = Awaitility + .await() + .atMost(Duration.ofMinutes(1)) + .pollInterval(Duration.ofSeconds(1)); + + List labelValues = labels + .entrySet() + .stream() + .map(it -> it.getKey() + "=" + it.getValue()) + .collect(Collectors.toList()); + + awaitFactory.untilAsserted(() -> { + assertThat(client.listContainersCmd().withFilter("label", labelValues).withShowAll(true).exec()).isEmpty(); + }); + + awaitFactory.untilAsserted(() -> { + assertThat(client.listNetworksCmd().withFilter("label", labelValues).exec()).isEmpty(); + }); + + awaitFactory.untilAsserted(() -> { + assertThat(client.listVolumesCmd().withFilter("label", labelValues).exec().getVolumes()).isEmpty(); + }); + } + + @SneakyThrows + private Map runProcess(Consumer processExecutorConsumer) { + ProcessExecutor processExecutor = new ProcessExecutor( + new File(System.getProperty("java.home")).toPath().resolve("bin").resolve("java").toString(), + "-ea", + "-classpath", + System.getProperty("java.class.path"), + SimpleUsage.class.getName() + ); + processExecutor.readOutput(true); + processExecutor.redirectOutput(System.out); + processExecutor.redirectError(System.err); + processExecutorConsumer.accept(processExecutor); + + ProcessResult result = processExecutor.execute(); + assertThat(result.getExitValue()).isEqualTo(0); + + String labelsJson = Stream + .of(result.outputUTF8().split("\n")) + .filter(it -> it.startsWith(SimpleUsage.LABELS_MARKER)) + .map(it -> it.substring(SimpleUsage.LABELS_MARKER.length())) + .findFirst() + .get(); + + return new ObjectMapper().readValue(labelsJson, Map.class); + } + + public static class SimpleUsage { + + static final String LABELS_MARKER = "LABELS:"; + + @SneakyThrows + @SuppressWarnings("deprecation") + public static void main(String[] args) { + System.out.println( + LABELS_MARKER + new ObjectMapper().writeValueAsString(ResourceReaper.instance().getLabels()) + ); + + GenericContainer container = new GenericContainer<>("testcontainers/helloworld:1.1.0") + .withNetwork(org.testcontainers.containers.Network.newNetwork()) + .withExposedPorts(8080); + + container.start(); + } + } +} diff --git a/core/src/test/java/org/testcontainers/utility/TestEnvironmentTest.java b/core/src/test/java/org/testcontainers/utility/TestEnvironmentTest.java index f34df95137c..874f623cc08 100644 --- a/core/src/test/java/org/testcontainers/utility/TestEnvironmentTest.java +++ b/core/src/test/java/org/testcontainers/utility/TestEnvironmentTest.java @@ -1,31 +1,39 @@ package org.testcontainers.utility; -import org.junit.Test; +import org.junit.jupiter.api.Test; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; /** * Created by rnorth on 03/07/2016. */ -public class TestEnvironmentTest { +class TestEnvironmentTest { @Test - public void testCompareVersionGreaterThanSameMajor() { - assertTrue("1.22 > 1.20", new ComparableVersion("1.22").compareTo(new ComparableVersion("1.20")) == 1); + void testCompareVersionGreaterThanSameMajor() { + assertThat(new ComparableVersion("1.22").compareTo(new ComparableVersion("1.20")) > 0) + .as("1.22 > 1.20") + .isTrue(); } @Test - public void testCompareVersionEqual() { - assertTrue("1.20 == 1.20", new ComparableVersion("1.20").compareTo(new ComparableVersion("1.20")) == 0); + void testCompareVersionEqual() { + assertThat(new ComparableVersion("1.20")) + .as("1.20 == 1.20") + .isEqualByComparingTo(new ComparableVersion("1.20")); } @Test - public void testCompareVersionGreaterThan() { - assertTrue("2.10 > 1.20", new ComparableVersion("2.10").compareTo(new ComparableVersion("1.20")) == 1); + void testCompareVersionGreaterThan() { + assertThat(new ComparableVersion("2.10").compareTo(new ComparableVersion("1.20")) > 0) + .as("2.10 > 1.20") + .isTrue(); } @Test - public void testCompareVersionIgnoresExcessLength() { - assertTrue("1.20 == 1.20.3", new ComparableVersion("1.20").compareTo(new ComparableVersion("1.20.3")) == 0); + void testCompareVersionIgnoresExcessLength() { + assertThat(new ComparableVersion("1.20")) + .as("1.20 == 1.20.3") + .isEqualByComparingTo(new ComparableVersion("1.20.3")); } } diff --git a/core/src/test/java/org/testcontainers/utility/TestcontainersConfigurationTest.java b/core/src/test/java/org/testcontainers/utility/TestcontainersConfigurationTest.java new file mode 100644 index 00000000000..4e0520af5ba --- /dev/null +++ b/core/src/test/java/org/testcontainers/utility/TestcontainersConfigurationTest.java @@ -0,0 +1,231 @@ +package org.testcontainers.utility; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class TestcontainersConfigurationTest { + + private Properties userProperties; + + private Properties classpathProperties; + + private Map environment; + + @BeforeEach + public void setUp() { + userProperties = new Properties(); + classpathProperties = new Properties(); + environment = new HashMap<>(); + } + + @Test + void shouldSubstituteImageNamesFromClasspathProperties() { + classpathProperties.setProperty("ryuk.container.image", "foo:version"); + assertThat(newConfig().getConfiguredSubstituteImage(DockerImageName.parse("testcontainers/ryuk:any"))) + .as("an image name can be pulled from classpath properties") + .isEqualTo(DockerImageName.parse("foo:version")); + } + + @Test + void shouldSubstituteImageNamesFromUserProperties() { + userProperties.setProperty("ryuk.container.image", "foo:version"); + assertThat(newConfig().getConfiguredSubstituteImage(DockerImageName.parse("testcontainers/ryuk:any"))) + .as("an image name can be pulled from user properties") + .isEqualTo(DockerImageName.parse("foo:version")); + } + + @Test + void shouldSubstituteImageNamesFromEnvironmentVariables() { + environment.put("TESTCONTAINERS_RYUK_CONTAINER_IMAGE", "foo:version"); + assertThat(newConfig().getConfiguredSubstituteImage(DockerImageName.parse("testcontainers/ryuk:any"))) + .as("an image name can be pulled from an environment variable") + .isEqualTo(DockerImageName.parse("foo:version")); + } + + @Test + void shouldApplySettingsInOrder() { + assertThat(newConfig().getEnvVarOrProperty("key", "default")) + .as("precedence order for multiple sources of the same value is correct") + .isEqualTo("default"); + + classpathProperties.setProperty("key", "foo"); + + assertThat(newConfig().getEnvVarOrProperty("key", "default")) + .as("precedence order for multiple sources of the same value is correct") + .isEqualTo("foo"); + + userProperties.setProperty("key", "bar"); + + assertThat(newConfig().getEnvVarOrProperty("key", "default")) + .as("precedence order for multiple sources of the same value is correct") + .isEqualTo("bar"); + + environment.put("TESTCONTAINERS_KEY", "baz"); + + assertThat(newConfig().getEnvVarOrProperty("key", "default")) + .as("precedence order for multiple sources of the same value is correct") + .isEqualTo("baz"); + } + + @Test + void shouldNotReadChecksFromClasspathProperties() { + assertThat(newConfig().isDisableChecks()).as("checks enabled by default").isFalse(); + + classpathProperties.setProperty("checks.disable", "true"); + assertThat(newConfig().isDisableChecks()).as("checks are not affected by classpath properties").isFalse(); + } + + @Test + void shouldReadChecksFromUserProperties() { + assertThat(newConfig().isDisableChecks()).as("checks enabled by default").isFalse(); + + userProperties.setProperty("checks.disable", "true"); + assertThat(newConfig().isDisableChecks()).as("checks disabled via user properties").isTrue(); + } + + @Test + void shouldReadChecksFromEnvironment() { + assertThat(newConfig().isDisableChecks()).as("checks enabled by default").isFalse(); + + userProperties.remove("checks.disable"); + environment.put("TESTCONTAINERS_CHECKS_DISABLE", "true"); + assertThat(newConfig().isDisableChecks()).as("checks disabled via env var").isTrue(); + } + + @Test + void shouldReadDockerSettingsFromEnvironmentWithoutTestcontainersPrefix() { + userProperties.remove("docker.foo"); + environment.put("DOCKER_FOO", "some value"); + assertThat(newConfig().getEnvVarOrUserProperty("docker.foo", "default")) + .as("reads unprefixed env vars for docker. settings") + .isEqualTo("some value"); + } + + @Test + void shouldNotReadDockerSettingsFromEnvironmentWithTestcontainersPrefix() { + userProperties.remove("docker.foo"); + environment.put("TESTCONTAINERS_DOCKER_FOO", "some value"); + assertThat(newConfig().getEnvVarOrUserProperty("docker.foo", "default")) + .as("reads unprefixed env vars for docker. settings") + .isEqualTo("default"); + } + + @Test + void shouldReadDockerSettingsFromUserProperties() { + environment.remove("DOCKER_FOO"); + userProperties.put("docker.foo", "some value"); + assertThat(newConfig().getEnvVarOrUserProperty("docker.foo", "default")) + .as("reads unprefixed user properties for docker. settings") + .isEqualTo("some value"); + } + + @Test + void shouldNotReadSettingIfCorrespondingEnvironmentVarIsEmptyString() { + environment.put("DOCKER_FOO", ""); + assertThat(newConfig().getEnvVarOrUserProperty("docker.foo", "default")) + .as("reads unprefixed env vars for docker. settings") + .isEqualTo("default"); + } + + @Test + void shouldNotReadDockerClientStrategyFromClasspathProperties() { + String currentValue = newConfig().getDockerClientStrategyClassName(); + + classpathProperties.setProperty("docker.client.strategy", UUID.randomUUID().toString()); + assertThat(newConfig().getDockerClientStrategyClassName()) + .as("Docker client strategy is not affected by classpath properties") + .isEqualTo(currentValue); + } + + @Test + void shouldReadDockerClientStrategyFromUserProperties() { + userProperties.setProperty("docker.client.strategy", "foo"); + assertThat(newConfig().getDockerClientStrategyClassName()) + .as("Docker client strategy is changed by user property") + .isEqualTo("foo"); + } + + @Test + void shouldReadDockerClientStrategyFromEnvironment() { + userProperties.remove("docker.client.strategy"); + environment.put("TESTCONTAINERS_DOCKER_CLIENT_STRATEGY", "foo"); + assertThat(newConfig().getDockerClientStrategyClassName()) + .as("Docker client strategy is changed by env var") + .isEqualTo("foo"); + } + + @Test + void shouldNotUseImplicitDockerClientStrategyWhenDockerHostAndStrategyAreBothSet() { + userProperties.put("docker.client.strategy", "foo"); + userProperties.put("docker.host", "tcp://1.2.3.4:5678"); + assertThat(newConfig().getDockerClientStrategyClassName()) + .as("Docker client strategy is can be explicitly set") + .isEqualTo("foo"); + + userProperties.remove("docker.client.strategy"); + + environment.put("TESTCONTAINERS_DOCKER_CLIENT_STRATEGY", "bar"); + userProperties.put("docker.client.strategy", "foo"); + assertThat(newConfig().getDockerClientStrategyClassName()) + .as("Docker client strategy is can be explicitly set") + .isEqualTo("bar"); + + environment.put("TESTCONTAINERS_DOCKER_CLIENT_STRATEGY", "bar"); + userProperties.remove("docker.client.strategy"); + assertThat(newConfig().getDockerClientStrategyClassName()) + .as("Docker client strategy is can be explicitly set") + .isEqualTo("bar"); + + environment.remove("TESTCONTAINERS_DOCKER_CLIENT_STRATEGY"); + userProperties.put("docker.client.strategy", "foo"); + assertThat(newConfig().getDockerClientStrategyClassName()) + .as("Docker client strategy is can be explicitly set") + .isEqualTo("foo"); + } + + @Test + void shouldNotReadReuseFromClasspathProperties() { + assertThat(newConfig().environmentSupportsReuse()).as("no reuse by default").isFalse(); + + classpathProperties.setProperty("testcontainers.reuse.enable", "true"); + assertThat(newConfig().environmentSupportsReuse()) + .as("reuse is not affected by classpath properties") + .isFalse(); + } + + @Test + void shouldReadReuseFromUserProperties() { + assertThat(newConfig().environmentSupportsReuse()).as("no reuse by default").isFalse(); + + userProperties.setProperty("testcontainers.reuse.enable", "true"); + assertThat(newConfig().environmentSupportsReuse()).as("reuse enabled via user property").isTrue(); + } + + @Test + void shouldReadReuseFromEnvironment() { + assertThat(newConfig().environmentSupportsReuse()).as("no reuse by default").isFalse(); + + userProperties.remove("testcontainers.reuse.enable"); + environment.put("TESTCONTAINERS_REUSE_ENABLE", "true"); + assertThat(newConfig().environmentSupportsReuse()).as("reuse enabled via env var").isTrue(); + } + + @Test + void shouldTrimImageNames() { + userProperties.setProperty("ryuk.container.image", " testcontainers/ryuk:0.3.2 "); + assertThat(newConfig().getRyukImage()) + .as("trailing whitespace was not removed from image name property") + .isEqualTo("testcontainers/ryuk:0.3.2"); + } + + private TestcontainersConfiguration newConfig() { + return new TestcontainersConfiguration(userProperties, classpathProperties, environment); + } +} diff --git a/core/src/test/resources/Dockerfile b/core/src/test/resources/Dockerfile new file mode 100644 index 00000000000..d3f2fcb60f7 --- /dev/null +++ b/core/src/test/resources/Dockerfile @@ -0,0 +1 @@ +FROM postgres diff --git a/core/src/test/resources/Dockerfile-multistage b/core/src/test/resources/Dockerfile-multistage new file mode 100644 index 00000000000..363b02b22ea --- /dev/null +++ b/core/src/test/resources/Dockerfile-multistage @@ -0,0 +1,9 @@ +FROM alpine:3.14 AS builder + +WORKDIR /my-files + +RUN echo 'Hello World' > hello.txt + +FROM alpine:3.14 + +COPY --from=builder /my-files/hello.txt hello.txt diff --git a/core/src/test/resources/META-INF/services/org.testcontainers.core.CreateContainerCmdModifier b/core/src/test/resources/META-INF/services/org.testcontainers.core.CreateContainerCmdModifier new file mode 100644 index 00000000000..c4eb86e2cbc --- /dev/null +++ b/core/src/test/resources/META-INF/services/org.testcontainers.core.CreateContainerCmdModifier @@ -0,0 +1 @@ +org.testcontainers.custom.TestCreateContainerCmdModifier diff --git a/core/src/test/resources/auth-config/config-basic-auth.json b/core/src/test/resources/auth-config/config-basic-auth.json new file mode 100644 index 00000000000..4a2c6983058 --- /dev/null +++ b/core/src/test/resources/auth-config/config-basic-auth.json @@ -0,0 +1,8 @@ +{ + "auths": { + "https://registry.example.com": { + "email": "user@example.com", + "auth": "dXNlcjpwYXNz" + } + } +} diff --git a/core/src/test/resources/auth-config/config-empty-auth-with-helper.json b/core/src/test/resources/auth-config/config-empty-auth-with-helper.json new file mode 100644 index 00000000000..8d8864815e3 --- /dev/null +++ b/core/src/test/resources/auth-config/config-empty-auth-with-helper.json @@ -0,0 +1,10 @@ +{ + "auths": { + }, + "HttpHeaders": { + "User-Agent": "Docker-Client/18.03.0-ce (darwin)" + }, + "credHelpers": { + "registry.example.com": "fake" + } +} diff --git a/core/src/test/resources/auth-config/config-empty.json b/core/src/test/resources/auth-config/config-empty.json new file mode 100644 index 00000000000..38a103a2459 --- /dev/null +++ b/core/src/test/resources/auth-config/config-empty.json @@ -0,0 +1,5 @@ +{ + "HttpHeaders": { + "User-Agent": "Docker-Client/18.03.0-ce (darwin)" + } +} diff --git a/core/src/test/resources/auth-config/config-existing-auth-with-helper.json b/core/src/test/resources/auth-config/config-existing-auth-with-helper.json new file mode 100644 index 00000000000..7e0fd003342 --- /dev/null +++ b/core/src/test/resources/auth-config/config-existing-auth-with-helper.json @@ -0,0 +1,14 @@ +{ + "auths": { + "https://registry.example.com": { + "email": "not@val.id", + "auth": "dXNlcjpwYXNz" + } + }, + "HttpHeaders": { + "User-Agent": "Docker-Client/18.03.0-ce (darwin)" + }, + "credHelpers": { + "registry.example.com": "fake" + } +} diff --git a/core/src/test/resources/auth-config/config-with-helper-and-store.json b/core/src/test/resources/auth-config/config-with-helper-and-store.json new file mode 100644 index 00000000000..cd623369d78 --- /dev/null +++ b/core/src/test/resources/auth-config/config-with-helper-and-store.json @@ -0,0 +1,12 @@ +{ + "auths": { + "registry.example.com": {} + }, + "HttpHeaders": { + "User-Agent": "Docker-Client/18.03.0-ce (darwin)" + }, + "credsStore": "fake", + "credHelpers": { + "registry.example.com": "fake" + } +} diff --git a/core/src/test/resources/auth-config/config-with-helper-no-server-url-using-token.json b/core/src/test/resources/auth-config/config-with-helper-no-server-url-using-token.json new file mode 100644 index 00000000000..836204cd7fb --- /dev/null +++ b/core/src/test/resources/auth-config/config-with-helper-no-server-url-using-token.json @@ -0,0 +1,11 @@ +{ + "auths": { + "registrynoserverurltoken.example.com": {} + }, + "HttpHeaders": { + "User-Agent": "Docker-Client/18.03.0-ce (darwin)" + }, + "credHelpers": { + "registrynoserverurltoken.example.com": "fake" + } +} diff --git a/core/src/test/resources/auth-config/config-with-helper-no-server-url.json b/core/src/test/resources/auth-config/config-with-helper-no-server-url.json new file mode 100644 index 00000000000..2ebe514d547 --- /dev/null +++ b/core/src/test/resources/auth-config/config-with-helper-no-server-url.json @@ -0,0 +1,11 @@ +{ + "auths": { + "registrynoserverurl.example.com": {} + }, + "HttpHeaders": { + "User-Agent": "Docker-Client/18.03.0-ce (darwin)" + }, + "credHelpers": { + "registrynoserverurl.example.com": "fake" + } +} diff --git a/core/src/test/resources/auth-config/config-with-helper-using-token.json b/core/src/test/resources/auth-config/config-with-helper-using-token.json new file mode 100644 index 00000000000..63b10a1ef5e --- /dev/null +++ b/core/src/test/resources/auth-config/config-with-helper-using-token.json @@ -0,0 +1,11 @@ +{ + "auths": { + "registrytoken.example.com": {} + }, + "HttpHeaders": { + "User-Agent": "Docker-Client/18.03.0-ce (darwin)" + }, + "credHelpers": { + "registrytoken.example.com": "fake" + } +} diff --git a/core/src/test/resources/auth-config/config-with-helper.json b/core/src/test/resources/auth-config/config-with-helper.json new file mode 100644 index 00000000000..eaa670e4296 --- /dev/null +++ b/core/src/test/resources/auth-config/config-with-helper.json @@ -0,0 +1,11 @@ +{ + "auths": { + "registry.example.com": {} + }, + "HttpHeaders": { + "User-Agent": "Docker-Client/18.03.0-ce (darwin)" + }, + "credHelpers": { + "registry.example.com": "fake" + } +} diff --git a/core/src/test/resources/auth-config/config-with-json-key.json b/core/src/test/resources/auth-config/config-with-json-key.json new file mode 100644 index 00000000000..3584963d645 --- /dev/null +++ b/core/src/test/resources/auth-config/config-with-json-key.json @@ -0,0 +1,7 @@ +{ + "auths": { + "https://registry.example.com": { + "auth": "X2pzb25fa2V5OnsKInR5cGUiOiAic2VydmljZV9hY2NvdW50IiwKInByb2plY3RfaWQiOiAiZXhhbXBsZS1wcm9qZWN0IiwKInByaXZhdGVfa2V5X2lkIjogImV4YW1wbGUta2V5LWlkIiwKInByaXZhdGVfa2V5IjogImV4YW1wbGUtcHJpdmF0ZS1rZXkiLAoiY2xpZW50X2VtYWlsIjogInVzZXJAZXhhbXBsZS5jb20iLAoiY2xpZW50X2lkIjogInVzZXJAZXhhbXBsZS5jb20iLAoiYXV0aF91cmkiOiAiaHR0cHM6Ly9yZWdpc3RyeS5leGFtcGxlLmNvbS9vL29hdXRoMi9hdXRoIiwKInRva2VuX3VyaSI6ICJodHRwczovL3JlZ2lzdHJ5LmV4YW1wbGUuY29tL3Rva2VuIiwKImF1dGhfcHJvdmlkZXJfeDUwOV9jZXJ0X3VybCI6ICJodHRwczovL3JlZ2lzdHJ5LmV4YW1wbGUuY29tIiwKImNsaWVudF94NTA5X2NlcnRfdXJsIjogImh0dHBzOi8vcmVnaXN0cnkuZXhhbXBsZS5jb20iCn0=" + } + } +} diff --git a/core/src/test/resources/auth-config/config-with-store-empty.json b/core/src/test/resources/auth-config/config-with-store-empty.json new file mode 100644 index 00000000000..4106187ff1e --- /dev/null +++ b/core/src/test/resources/auth-config/config-with-store-empty.json @@ -0,0 +1,9 @@ +{ + "auths": { + "registry.example.com": {} + }, + "HttpHeaders": { + "User-Agent": "Docker-Client/18.03.0-ce (darwin)" + }, + "credsStore": "" +} diff --git a/core/src/test/resources/auth-config/config-with-store.json b/core/src/test/resources/auth-config/config-with-store.json new file mode 100644 index 00000000000..d165997e52f --- /dev/null +++ b/core/src/test/resources/auth-config/config-with-store.json @@ -0,0 +1,9 @@ +{ + "auths": { + "registry.example.com": {} + }, + "HttpHeaders": { + "User-Agent": "Docker-Client/18.03.0-ce (darwin)" + }, + "credsStore": "fake" +} diff --git a/core/src/test/resources/auth-config/docker-credential-fake b/core/src/test/resources/auth-config/docker-credential-fake new file mode 100755 index 00000000000..3eb54b35a7e --- /dev/null +++ b/core/src/test/resources/auth-config/docker-credential-fake @@ -0,0 +1,45 @@ +#!/bin/sh + +if [ $1 != "get" ]; then + exit 1 +fi + +read inputLine + +if [ "$inputLine" = "registry2.example.com" ]; then + echo Fake credentials not found on credentials store \'$inputLine\' 0>&2 + exit 1 +fi + +if [ "$inputLine" = "registry.example.com" ]; then + echo '{' \ + ' "ServerURL": "url",' \ + ' "Username": "username",' \ + ' "Secret": "secret"' \ + '}' + exit 0 +fi +if [ "$inputLine" = "registrytoken.example.com" ]; then + echo '{' \ + ' "ServerURL": "url",' \ + ' "Username": "",' \ + ' "Secret": "secret"' \ + '}' + exit 0 +fi +if [ "$inputLine" = "registrynoserverurl.example.com" ]; then + echo '{' \ + ' "Username": "username",' \ + ' "Secret": "secret"' \ + '}' + exit 0 +fi +if [ "$inputLine" = "registrynoserverurltoken.example.com" ]; then + echo '{' \ + ' "Username": "",' \ + ' "Secret": "secret"' \ + '}' + exit 0 +fi + +exit 1 diff --git a/core/src/test/resources/auth-config/win/docker-credential-fake.bat b/core/src/test/resources/auth-config/win/docker-credential-fake.bat new file mode 100644 index 00000000000..afb4c66f1c6 --- /dev/null +++ b/core/src/test/resources/auth-config/win/docker-credential-fake.bat @@ -0,0 +1,44 @@ +@echo off +if not "%1" == "get" ( + exit 1 +) + +set /p inputLine="" + +if "%inputLine%" == "registry2.example.com" ( + echo Fake credentials not found on credentials store '%inputLine%' 0>&2 + exit 1 +) + +if "%inputLine%" == "registry.example.com" ( + echo { + echo "ServerURL": "url", + echo "Username": "username", + echo "Secret": "secret" + echo } + exit 0 +) +if "%inputLine%" == "registrytoken.example.com" ( + echo { + echo "ServerURL": "url", + echo "Username": "", + echo "Secret": "secret" + echo } + exit 0 +) +if "%inputLine%" == "registrynoserverurl.example.com" ( + echo { + echo "Username": "username", + echo "Secret": "secret" + echo } + exit 0 +) +if "%inputLine%" == "registrynoserverurltoken.example.com" ( + echo { + echo "Username": "", + echo "Secret": "secret" + echo } + exit 0 +) + +exit 1 diff --git a/core/src/test/resources/compose-build-test/Dockerfile b/core/src/test/resources/compose-build-test/Dockerfile new file mode 100644 index 00000000000..05896acf980 --- /dev/null +++ b/core/src/test/resources/compose-build-test/Dockerfile @@ -0,0 +1,3 @@ +FROM redis:6.0-alpine + +CMD ["redis-server"] diff --git a/core/src/test/resources/compose-build-test/docker-compose.yml b/core/src/test/resources/compose-build-test/docker-compose.yml new file mode 100644 index 00000000000..f2b5460570c --- /dev/null +++ b/core/src/test/resources/compose-build-test/docker-compose.yml @@ -0,0 +1,6 @@ +version: '2.0' +services: + customredis: + build: . + normalredis: + image: redis:6.0-alpine diff --git a/core/src/test/resources/compose-dockerfile/Dockerfile b/core/src/test/resources/compose-dockerfile/Dockerfile index 64613d2cd32..7450ebde82c 100644 --- a/core/src/test/resources/compose-dockerfile/Dockerfile +++ b/core/src/test/resources/compose-dockerfile/Dockerfile @@ -1,5 +1,5 @@ -FROM alpine:3.2 +FROM alpine:3.17 ADD passthrough.sh /passthrough.sh -CMD /passthrough.sh \ No newline at end of file +CMD /passthrough.sh diff --git a/core/src/test/resources/compose-file-copy-inclusions/.env b/core/src/test/resources/compose-file-copy-inclusions/.env new file mode 100644 index 00000000000..bb5b5beb3e2 --- /dev/null +++ b/core/src/test/resources/compose-file-copy-inclusions/.env @@ -0,0 +1 @@ +MY_ENV_VARIABLE=original diff --git a/core/src/test/resources/compose-file-copy-inclusions/Dockerfile b/core/src/test/resources/compose-file-copy-inclusions/Dockerfile new file mode 100644 index 00000000000..851086d68c2 --- /dev/null +++ b/core/src/test/resources/compose-file-copy-inclusions/Dockerfile @@ -0,0 +1,9 @@ +FROM jbangdev/jbang-action + +WORKDIR /app +COPY EnvVariableRestEndpoint.java . + +RUN jbang export portable --force EnvVariableRestEndpoint.java + +EXPOSE 8080 +CMD ["./EnvVariableRestEndpoint.java"] diff --git a/core/src/test/resources/compose-file-copy-inclusions/EnvVariableRestEndpoint.java b/core/src/test/resources/compose-file-copy-inclusions/EnvVariableRestEndpoint.java new file mode 100644 index 00000000000..ead172bbb35 --- /dev/null +++ b/core/src/test/resources/compose-file-copy-inclusions/EnvVariableRestEndpoint.java @@ -0,0 +1,45 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? + +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpExchange; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; + +public class EnvVariableRestEndpoint { + private static final String ENV_VARIABLE_NAME = "MY_ENV_VARIABLE"; + private static final int PORT = 8080; + + public static void main(String[] args) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(PORT), 0); + server.createContext("/env", new EnvVariableHandler()); + server.setExecutor(null); + server.start(); + System.out.println("Server started on port " + PORT); + } + + static class EnvVariableHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + if ("GET".equals(exchange.getRequestMethod())) { + String envValue = System.getenv(ENV_VARIABLE_NAME); + String response = envValue != null + ? ENV_VARIABLE_NAME + ": " + envValue + : "Environment variable " + ENV_VARIABLE_NAME + " not found"; + + exchange.sendResponseHeaders(200, response.length()); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response.getBytes()); + } + } else { + String response = "Method not allowed"; + exchange.sendResponseHeaders(405, response.length()); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response.getBytes()); + } + } + } + } +} diff --git a/core/src/test/resources/compose-file-copy-inclusions/compose-root-only.yml b/core/src/test/resources/compose-file-copy-inclusions/compose-root-only.yml new file mode 100644 index 00000000000..31ad1216532 --- /dev/null +++ b/core/src/test/resources/compose-file-copy-inclusions/compose-root-only.yml @@ -0,0 +1,7 @@ +services: + app: + build: . + ports: + - "8080" + env_file: + - '.env' diff --git a/core/src/test/resources/compose-file-copy-inclusions/compose-test-only.yml b/core/src/test/resources/compose-file-copy-inclusions/compose-test-only.yml new file mode 100644 index 00000000000..ddc069664ab --- /dev/null +++ b/core/src/test/resources/compose-file-copy-inclusions/compose-test-only.yml @@ -0,0 +1,7 @@ +services: + app: + build: . + ports: + - "8080" + env_file: + - './test/.env' diff --git a/core/src/test/resources/compose-file-copy-inclusions/compose.yml b/core/src/test/resources/compose-file-copy-inclusions/compose.yml new file mode 100644 index 00000000000..4ea26f671f7 --- /dev/null +++ b/core/src/test/resources/compose-file-copy-inclusions/compose.yml @@ -0,0 +1,8 @@ +services: + app: + build: . + ports: + - "8080" + env_file: + - '.env' + - './test/.env' diff --git a/core/src/test/resources/compose-file-copy-inclusions/test/.env b/core/src/test/resources/compose-file-copy-inclusions/test/.env new file mode 100644 index 00000000000..09867ae6d5d --- /dev/null +++ b/core/src/test/resources/compose-file-copy-inclusions/test/.env @@ -0,0 +1 @@ +MY_ENV_VARIABLE=override diff --git a/core/src/test/resources/compose-options-test/with-deploy-block.yml b/core/src/test/resources/compose-options-test/with-deploy-block.yml new file mode 100644 index 00000000000..b80c8677fb8 --- /dev/null +++ b/core/src/test/resources/compose-options-test/with-deploy-block.yml @@ -0,0 +1,8 @@ +version: '3.7' +services: + redis: + image: redis:6-alpine + deploy: + resources: + limits: + memory: 150M diff --git a/core/src/test/resources/compose-override/compose-override.yml b/core/src/test/resources/compose-override/compose-override.yml new file mode 100644 index 00000000000..7dbf6ff5987 --- /dev/null +++ b/core/src/test/resources/compose-override/compose-override.yml @@ -0,0 +1,9 @@ +services: + redis: + image: redis:6-alpine + ports: + - 6379 + environment: + foo: !reset null + labels: !override + label2: value2 diff --git a/core/src/test/resources/compose-override/compose.yml b/core/src/test/resources/compose-override/compose.yml new file mode 100644 index 00000000000..9e4a1e9169a --- /dev/null +++ b/core/src/test/resources/compose-override/compose.yml @@ -0,0 +1,9 @@ +services: + redis: + image: redis:6-alpine + ports: + - 6379 + environment: + foo: bar + labels: + label1: value1 diff --git a/core/src/test/resources/compose-profile-option/compose-test.yml b/core/src/test/resources/compose-profile-option/compose-test.yml new file mode 100644 index 00000000000..8d85bf49967 --- /dev/null +++ b/core/src/test/resources/compose-profile-option/compose-test.yml @@ -0,0 +1,11 @@ +services: + redis: + image: redis + profiles: + - cache + db: + image: mysql:8.0.36 + environment: + MYSQL_RANDOM_ROOT_PASSWORD: "true" + profiles: + - db diff --git a/core/src/test/resources/compose-scaling-multiple-containers.yml b/core/src/test/resources/compose-scaling-multiple-containers.yml new file mode 100644 index 00000000000..ce3f002f1e8 --- /dev/null +++ b/core/src/test/resources/compose-scaling-multiple-containers.yml @@ -0,0 +1,7 @@ +version: '2.4' +services: + redis: + image: redis + other: + image: alpine:3.17 + command: sleep 10000 diff --git a/core/src/test/resources/compose-test.yml b/core/src/test/resources/compose-test.yml index 2e54e9f30b1..25c70940daa 100644 --- a/core/src/test/resources/compose-test.yml +++ b/core/src/test/resources/compose-test.yml @@ -1,6 +1,6 @@ redis: image: redis db: - image: orchardup/mysql + image: mysql:8.0.36 environment: - MYSQL_DATABASE: wordpress \ No newline at end of file + MYSQL_RANDOM_ROOT_PASSWORD: "true" diff --git a/core/src/test/resources/compose-v2-build-test/Dockerfile b/core/src/test/resources/compose-v2-build-test/Dockerfile new file mode 100644 index 00000000000..1ad866e9522 --- /dev/null +++ b/core/src/test/resources/compose-v2-build-test/Dockerfile @@ -0,0 +1,3 @@ +FROM redis:7.0-alpine + +CMD ["redis-server"] diff --git a/core/src/test/resources/compose-v2-build-test/docker-compose.yml b/core/src/test/resources/compose-v2-build-test/docker-compose.yml new file mode 100644 index 00000000000..2b4bec78d45 --- /dev/null +++ b/core/src/test/resources/compose-v2-build-test/docker-compose.yml @@ -0,0 +1,6 @@ +version: '2.0' +services: + customredis: + build: . + normalredis: + image: redis:7.0-alpine diff --git a/core/src/test/resources/compose-with-inline-scale-test.yml b/core/src/test/resources/compose-with-inline-scale-test.yml new file mode 100644 index 00000000000..f2e7e8c1a79 --- /dev/null +++ b/core/src/test/resources/compose-with-inline-scale-test.yml @@ -0,0 +1,5 @@ +version: '2.4' +services: + redis: + image: redis + scale: 3 # legacy mechanism to specify scale diff --git a/core/src/test/resources/composev2/compose-test.yml b/core/src/test/resources/composev2/compose-test.yml new file mode 100644 index 00000000000..9dab984e10e --- /dev/null +++ b/core/src/test/resources/composev2/compose-test.yml @@ -0,0 +1,7 @@ +services: + redis: + image: redis + db: + image: mysql:8.0.36 + environment: + MYSQL_RANDOM_ROOT_PASSWORD: "true" diff --git a/core/src/test/resources/composev2/scaled-compose-test.yml b/core/src/test/resources/composev2/scaled-compose-test.yml new file mode 100644 index 00000000000..b1d6a2c9b76 --- /dev/null +++ b/core/src/test/resources/composev2/scaled-compose-test.yml @@ -0,0 +1,3 @@ +services: + redis: + image: redis diff --git a/core/src/test/resources/container-license-acceptance.txt b/core/src/test/resources/container-license-acceptance.txt new file mode 100644 index 00000000000..bfdaa0f1c34 --- /dev/null +++ b/core/src/test/resources/container-license-acceptance.txt @@ -0,0 +1,3 @@ +a +b + diff --git a/core/src/test/resources/docker-compose-base.yml b/core/src/test/resources/docker-compose-base.yml index c2dda256277..585d3dead39 100644 --- a/core/src/test/resources/docker-compose-base.yml +++ b/core/src/test/resources/docker-compose-base.yml @@ -1,6 +1,6 @@ -version: '2' +version: '2.1' services: alpine: build: compose-dockerfile environment: - bar: base \ No newline at end of file + bar: base diff --git a/core/src/test/resources/docker-compose-container-name-v1.yml b/core/src/test/resources/docker-compose-container-name-v1.yml new file mode 100644 index 00000000000..96af67248a0 --- /dev/null +++ b/core/src/test/resources/docker-compose-container-name-v1.yml @@ -0,0 +1,3 @@ +redis: + image: redis + container_name: redis diff --git a/core/src/test/resources/docker-compose-deserialization.yml b/core/src/test/resources/docker-compose-deserialization.yml new file mode 100644 index 00000000000..db7a22be665 --- /dev/null +++ b/core/src/test/resources/docker-compose-deserialization.yml @@ -0,0 +1,3 @@ +redis: + image: redis + key: !!org.testcontainers.containers.ParsedDockerComposeFileBean '{foo: bar}' diff --git a/core/src/test/resources/docker-compose-healthcheck.yml b/core/src/test/resources/docker-compose-healthcheck.yml new file mode 100644 index 00000000000..f4d6ed65386 --- /dev/null +++ b/core/src/test/resources/docker-compose-healthcheck.yml @@ -0,0 +1,6 @@ +version: "3.9" +services: + redis: + image: redis:alpine + healthcheck: + test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ] diff --git a/core/src/test/resources/docker-compose-imagename-overriding-a.yml b/core/src/test/resources/docker-compose-imagename-overriding-a.yml new file mode 100644 index 00000000000..50b226d32a4 --- /dev/null +++ b/core/src/test/resources/docker-compose-imagename-overriding-a.yml @@ -0,0 +1,12 @@ +version: "2.1" +services: + aservice: + image: aservice + redis: + image: redis:a + mysql: + image: mysql:a + custom: + build: . +networks: + custom_network: {} diff --git a/core/src/test/resources/docker-compose-imagename-overriding-b.yml b/core/src/test/resources/docker-compose-imagename-overriding-b.yml new file mode 100644 index 00000000000..7e6f83c6b6a --- /dev/null +++ b/core/src/test/resources/docker-compose-imagename-overriding-b.yml @@ -0,0 +1,10 @@ +version: "2.1" +services: + redis: + image: redis:b + mysql: + image: mysql:b + custom: + build: compose-dockerfile +networks: + custom_network: {} diff --git a/core/src/test/resources/docker-compose-imagename-parsing-dockerfile-with-context.yml b/core/src/test/resources/docker-compose-imagename-parsing-dockerfile-with-context.yml new file mode 100644 index 00000000000..4e86b83720b --- /dev/null +++ b/core/src/test/resources/docker-compose-imagename-parsing-dockerfile-with-context.yml @@ -0,0 +1,12 @@ +version: "2.1" +services: + redis: + image: redis + mysql: + image: mysql + custom: + build: + context: compose-dockerfile + dockerfile: Dockerfile +networks: + custom_network: {} diff --git a/core/src/test/resources/docker-compose-imagename-parsing-dockerfile.yml b/core/src/test/resources/docker-compose-imagename-parsing-dockerfile.yml new file mode 100644 index 00000000000..46d3da26f4c --- /dev/null +++ b/core/src/test/resources/docker-compose-imagename-parsing-dockerfile.yml @@ -0,0 +1,10 @@ +version: "2.1" +services: + redis: + image: redis + mysql: + image: mysql + custom: + build: compose-dockerfile +networks: + custom_network: {} diff --git a/core/src/test/resources/docker-compose-imagename-parsing-v1.yml b/core/src/test/resources/docker-compose-imagename-parsing-v1.yml new file mode 100644 index 00000000000..c3ae53fa3a8 --- /dev/null +++ b/core/src/test/resources/docker-compose-imagename-parsing-v1.yml @@ -0,0 +1,6 @@ +redis: + image: redis +mysql: + image: mysql +custom: + build: . diff --git a/core/src/test/resources/docker-compose-imagename-parsing-v2-no-version.yml b/core/src/test/resources/docker-compose-imagename-parsing-v2-no-version.yml new file mode 100644 index 00000000000..0b48ad3eb05 --- /dev/null +++ b/core/src/test/resources/docker-compose-imagename-parsing-v2-no-version.yml @@ -0,0 +1,9 @@ +services: + redis: + image: redis + mysql: + image: mysql + custom: + build: . +networks: + custom_network: {} diff --git a/core/src/test/resources/docker-compose-imagename-parsing-v2.yml b/core/src/test/resources/docker-compose-imagename-parsing-v2.yml new file mode 100644 index 00000000000..71ca8409f27 --- /dev/null +++ b/core/src/test/resources/docker-compose-imagename-parsing-v2.yml @@ -0,0 +1,10 @@ +version: "2.1" +services: + redis: + image: redis + mysql: + image: mysql + custom: + build: . +networks: + custom_network: {} diff --git a/core/src/test/resources/docker-compose-non-default-override.yml b/core/src/test/resources/docker-compose-non-default-override.yml index fa7acb70dfd..19f80a31c83 100644 --- a/core/src/test/resources/docker-compose-non-default-override.yml +++ b/core/src/test/resources/docker-compose-non-default-override.yml @@ -1,5 +1,5 @@ -version: '2' +version: '2.1' services: alpine: environment: - bar: overwritten \ No newline at end of file + bar: overwritten diff --git a/core/src/test/resources/dockerfile-build-invalid/.dockerignore b/core/src/test/resources/dockerfile-build-invalid/.dockerignore new file mode 100644 index 00000000000..5f06037fe04 --- /dev/null +++ b/core/src/test/resources/dockerfile-build-invalid/.dockerignore @@ -0,0 +1,3 @@ +# This is an invalid dockerignore file +*~ +[a-b-c] \ No newline at end of file diff --git a/core/src/test/resources/dockerfile-build-invalid/Dockerfile b/core/src/test/resources/dockerfile-build-invalid/Dockerfile new file mode 100644 index 00000000000..66663986d13 --- /dev/null +++ b/core/src/test/resources/dockerfile-build-invalid/Dockerfile @@ -0,0 +1 @@ +FROM alpine:3.16 diff --git a/core/src/test/resources/dockerfile-build-test/.dockerignore b/core/src/test/resources/dockerfile-build-test/.dockerignore new file mode 100644 index 00000000000..a6bd49d7d81 --- /dev/null +++ b/core/src/test/resources/dockerfile-build-test/.dockerignore @@ -0,0 +1 @@ +should_be_ignored.txt diff --git a/core/src/test/resources/dockerfile-build-test/Dockerfile b/core/src/test/resources/dockerfile-build-test/Dockerfile new file mode 100644 index 00000000000..3f341034fef --- /dev/null +++ b/core/src/test/resources/dockerfile-build-test/Dockerfile @@ -0,0 +1,2 @@ +FROM alpine:3.16 +COPY localfile.txt /test.txt diff --git a/core/src/test/resources/dockerfile-build-test/Dockerfile-alt b/core/src/test/resources/dockerfile-build-test/Dockerfile-alt new file mode 100644 index 00000000000..fe36516c397 --- /dev/null +++ b/core/src/test/resources/dockerfile-build-test/Dockerfile-alt @@ -0,0 +1,2 @@ +FROM alpine:3.16 +RUN echo "test4567" > /test.txt diff --git a/core/src/test/resources/dockerfile-build-test/Dockerfile-buildarg b/core/src/test/resources/dockerfile-build-test/Dockerfile-buildarg new file mode 100644 index 00000000000..f137d39f0d2 --- /dev/null +++ b/core/src/test/resources/dockerfile-build-test/Dockerfile-buildarg @@ -0,0 +1,3 @@ +FROM alpine:3.16 +ARG CUSTOM_ARG +RUN echo "${CUSTOM_ARG}" > /test.txt diff --git a/core/src/test/resources/dockerfile-build-test/Dockerfile-currentdir b/core/src/test/resources/dockerfile-build-test/Dockerfile-currentdir new file mode 100644 index 00000000000..3a6bfd62f02 --- /dev/null +++ b/core/src/test/resources/dockerfile-build-test/Dockerfile-currentdir @@ -0,0 +1,2 @@ +FROM alpine:3.16 +COPY . / diff --git a/core/src/test/resources/dockerfile-build-test/Dockerfile-from-buildarg b/core/src/test/resources/dockerfile-build-test/Dockerfile-from-buildarg new file mode 100644 index 00000000000..16934b374e8 --- /dev/null +++ b/core/src/test/resources/dockerfile-build-test/Dockerfile-from-buildarg @@ -0,0 +1,9 @@ +ARG BUILD_IMAGE +ARG BASE_IMAGE +ARG BASE_IMAGE_TAG + +FROM ${BUILD_IMAGE} AS build +COPY localfile.txt /test-build.txt + +FROM $BASE_IMAGE:${BASE_IMAGE_TAG} AS base +COPY --from=build /test-build.txt /test.txt diff --git a/core/src/test/resources/dockerfile-build-test/localfile.txt b/core/src/test/resources/dockerfile-build-test/localfile.txt new file mode 100644 index 00000000000..5586d4f8333 --- /dev/null +++ b/core/src/test/resources/dockerfile-build-test/localfile.txt @@ -0,0 +1 @@ +test1234 diff --git a/core/src/test/resources/dockerfile-build-test/should_be_ignored.txt b/core/src/test/resources/dockerfile-build-test/should_be_ignored.txt new file mode 100644 index 00000000000..6b04bcc9cec --- /dev/null +++ b/core/src/test/resources/dockerfile-build-test/should_be_ignored.txt @@ -0,0 +1 @@ +this file should be ignored by a .dockerignore file \ No newline at end of file diff --git a/core/src/test/resources/dockerfile-build-test/should_not_be_ignored.txt b/core/src/test/resources/dockerfile-build-test/should_not_be_ignored.txt new file mode 100644 index 00000000000..675ce985900 --- /dev/null +++ b/core/src/test/resources/dockerfile-build-test/should_not_be_ignored.txt @@ -0,0 +1 @@ +this file should not be ignored by a .dockerignore file \ No newline at end of file diff --git a/core/src/test/resources/expectedClasspathFile.txt b/core/src/test/resources/expectedClasspathFile.txt new file mode 100644 index 00000000000..e13c06f078f --- /dev/null +++ b/core/src/test/resources/expectedClasspathFile.txt @@ -0,0 +1 @@ +This file exists for org.testcontainers.utility.ClasspathScannerTest diff --git a/core/src/test/resources/health-wait-strategy-dockerfile/Dockerfile b/core/src/test/resources/health-wait-strategy-dockerfile/Dockerfile new file mode 100644 index 00000000000..ed404c1f0d8 --- /dev/null +++ b/core/src/test/resources/health-wait-strategy-dockerfile/Dockerfile @@ -0,0 +1,8 @@ +FROM alpine:3.16 + +HEALTHCHECK --interval=1s CMD test -e /testfile + +COPY write_file_and_loop.sh write_file_and_loop.sh +RUN chmod +x write_file_and_loop.sh + +CMD ["/write_file_and_loop.sh"] diff --git a/core/src/test/resources/health-wait-strategy-dockerfile/write_file_and_loop.sh b/core/src/test/resources/health-wait-strategy-dockerfile/write_file_and_loop.sh new file mode 100755 index 00000000000..3a688ee3f4f --- /dev/null +++ b/core/src/test/resources/health-wait-strategy-dockerfile/write_file_and_loop.sh @@ -0,0 +1,8 @@ +#!/bin/ash + +set -ex + +sleep 2 +touch /testfile + +while true; do sleep 1; done diff --git a/core/src/test/resources/https-wait-strategy-dockerfile/Dockerfile b/core/src/test/resources/https-wait-strategy-dockerfile/Dockerfile new file mode 100644 index 00000000000..9a6d2245fb5 --- /dev/null +++ b/core/src/test/resources/https-wait-strategy-dockerfile/Dockerfile @@ -0,0 +1,6 @@ +FROM nginx:1.17-alpine + +# Create keypair and self-signed certificate for https test +RUN apk update && apk add bash openssl && openssl req -batch -x509 -nodes -days 365 -newkey rsa:2048 -subj "/CN=localhost" -keyout /etc/ssl/private/nginx-selfsigned.key -out /etc/ssl/certs/nginx-selfsigned.crt + +ADD nginx-ssl.conf /etc/nginx/conf.d/default.conf diff --git a/core/src/test/resources/https-wait-strategy-dockerfile/nginx-ssl.conf b/core/src/test/resources/https-wait-strategy-dockerfile/nginx-ssl.conf new file mode 100644 index 00000000000..021741f6b9e --- /dev/null +++ b/core/src/test/resources/https-wait-strategy-dockerfile/nginx-ssl.conf @@ -0,0 +1,10 @@ +# This configuration makes Nginx listen on port port 8443 +# In order to use this config, add this line to the Dockerfile to create the keypair and self-signed certificate: +# RUN apk update && apk add openssl && openssl req -batch -x509 -nodes -days 365 -newkey rsa:2048 -subj "/CN=localhost" -keyout /etc/ssl/private/nginx-selfsigned.key -out /etc/ssl/certs/nginx-selfsigned.crt + +server { + listen 8443 ssl; + server_name localhost; + ssl_certificate /etc/ssl/certs/nginx-selfsigned.crt; + ssl_certificate_key /etc/ssl/private/nginx-selfsigned.key; +} diff --git a/core/src/test/resources/internal-port-check-dockerfile/Dockerfile-bash b/core/src/test/resources/internal-port-check-dockerfile/Dockerfile-bash new file mode 100644 index 00000000000..02373da980e --- /dev/null +++ b/core/src/test/resources/internal-port-check-dockerfile/Dockerfile-bash @@ -0,0 +1,11 @@ +FROM nginx:1.17-alpine + +RUN apk add bash + +# Make sure the /proc/net/tcp* check fails in this container +RUN rm /usr/bin/awk + +# Make sure the nc check fails in this container +RUN rm /usr/bin/nc + +ADD nginx.conf /etc/nginx/conf.d/default.conf diff --git a/core/src/test/resources/internal-port-check-dockerfile/Dockerfile-nc b/core/src/test/resources/internal-port-check-dockerfile/Dockerfile-nc new file mode 100644 index 00000000000..f69b74a8754 --- /dev/null +++ b/core/src/test/resources/internal-port-check-dockerfile/Dockerfile-nc @@ -0,0 +1,9 @@ +FROM nginx:1.17-alpine + +# If this fails, you ended up using a base image with bash installed. Consider removing /bin/bash in this case +RUN if bash -c true &> /dev/null; then exit 1; fi + +# Make sure the /proc/net/tcp* check fails in this container +RUN rm /usr/bin/awk + +ADD nginx.conf /etc/nginx/conf.d/default.conf diff --git a/core/src/test/resources/internal-port-check-dockerfile/Dockerfile-tcp b/core/src/test/resources/internal-port-check-dockerfile/Dockerfile-tcp new file mode 100644 index 00000000000..8699db537aa --- /dev/null +++ b/core/src/test/resources/internal-port-check-dockerfile/Dockerfile-tcp @@ -0,0 +1,9 @@ +FROM nginx:1.17-alpine + +# If this fails, you ended up using a base image with bash installed. Consider removing /bin/bash in this case +RUN if bash -c true &> /dev/null; then exit 1; fi + +# Make sure the nc check fails in this container +RUN rm /usr/bin/nc + +ADD nginx.conf /etc/nginx/conf.d/default.conf diff --git a/core/src/test/resources/internal-port-check-dockerfile/nginx.conf b/core/src/test/resources/internal-port-check-dockerfile/nginx.conf new file mode 100644 index 00000000000..f69ddf345cf --- /dev/null +++ b/core/src/test/resources/internal-port-check-dockerfile/nginx.conf @@ -0,0 +1,8 @@ +# This configuration makes Nginx listen on port port 8080 + +server { + # Port 8080 is necessary to prove that the command formatting in the /proc/net/tcp* check uses the correct casing for hexadecimal numbers (i.e. 1F90 and not 1f90) + listen 8080; + # Port 100 is necessary to ensure that the /proc/net/tcp* check also succeeds with trailing zeros in the hexadecimal port + listen 100; +} diff --git a/core/src/test/resources/local-compose-test.yml b/core/src/test/resources/local-compose-test.yml new file mode 100644 index 00000000000..b5f61be041b --- /dev/null +++ b/core/src/test/resources/local-compose-test.yml @@ -0,0 +1,6 @@ +version: '2.1' +services: + redis: + image: redis-local:latest + ports: + - 6379 diff --git a/core/src/test/resources/logback-test.xml b/core/src/test/resources/logback-test.xml index ed0e5b2659e..7d4d9e8f4b4 100644 --- a/core/src/test/resources/logback-test.xml +++ b/core/src/test/resources/logback-test.xml @@ -8,22 +8,15 @@ - + - + + + - - - - - - - PROFILER - DENY - - \ No newline at end of file + diff --git a/core/src/test/resources/mappable-dockerfile/Dockerfile b/core/src/test/resources/mappable-dockerfile/Dockerfile index b1afd24a3ec..5b9f6a02783 100644 --- a/core/src/test/resources/mappable-dockerfile/Dockerfile +++ b/core/src/test/resources/mappable-dockerfile/Dockerfile @@ -1,6 +1,6 @@ -FROM alpine:3.2 +FROM alpine:3.16 ADD folder/someFile.txt /someFile.txt RUN cat /someFile.txt ADD test.txt /test.txt RUN cat /test.txt -CMD ping -c 5 www.google.com \ No newline at end of file +CMD ping -c 5 www.google.com diff --git a/core/src/test/resources/test-recursive-file.txt b/core/src/test/resources/test-recursive-file.txt new file mode 100644 index 00000000000..067480d972b --- /dev/null +++ b/core/src/test/resources/test-recursive-file.txt @@ -0,0 +1 @@ +Used for DirectoryTarResourceTest \ No newline at end of file diff --git a/core/src/test/resources/test_copy_to_container.txt b/core/src/test/resources/test_copy_to_container.txt new file mode 100644 index 00000000000..2c377842030 --- /dev/null +++ b/core/src/test/resources/test_copy_to_container.txt @@ -0,0 +1,2 @@ +Some Test +Message \ No newline at end of file diff --git a/core/src/test/resources/v2-compose-test-passthrough.yml b/core/src/test/resources/v2-compose-test-passthrough.yml index 6b0cd807158..f96aff26f75 100644 --- a/core/src/test/resources/v2-compose-test-passthrough.yml +++ b/core/src/test/resources/v2-compose-test-passthrough.yml @@ -1,6 +1,6 @@ -version: '2' +version: '2.1' services: alpine: build: compose-dockerfile environment: - bar: ${foo} \ No newline at end of file + bar: ${foo} diff --git a/core/src/test/resources/v2-compose-test-port-via-env.yml b/core/src/test/resources/v2-compose-test-port-via-env.yml new file mode 100644 index 00000000000..2b4929594be --- /dev/null +++ b/core/src/test/resources/v2-compose-test-port-via-env.yml @@ -0,0 +1,5 @@ +services: + redis: + image: redis + ports: + - ${REDIS_PORT} diff --git a/core/src/test/resources/v2-compose-test-with-network.yml b/core/src/test/resources/v2-compose-test-with-network.yml index 82b54a85823..89301c7918a 100644 --- a/core/src/test/resources/v2-compose-test-with-network.yml +++ b/core/src/test/resources/v2-compose-test-with-network.yml @@ -1,4 +1,4 @@ -version: '2' +version: '2.1' services: redis: image: redis diff --git a/core/src/test/resources/v2-compose-test.yml b/core/src/test/resources/v2-compose-test.yml index b264c61ebb6..a9090c55671 100644 --- a/core/src/test/resources/v2-compose-test.yml +++ b/core/src/test/resources/v2-compose-test.yml @@ -1,4 +1,6 @@ -version: '2' +version: '2.1' services: redis: - image: redis \ No newline at end of file + image: redis + ports: + - 6379 diff --git a/core/testlib/META-INF/dummy_unique_name.txt b/core/testlib/META-INF/dummy_unique_name.txt new file mode 100644 index 00000000000..48162f49928 --- /dev/null +++ b/core/testlib/META-INF/dummy_unique_name.txt @@ -0,0 +1 @@ +This is a file inside a JAR archive \ No newline at end of file diff --git a/core/testlib/README.md b/core/testlib/README.md new file mode 100644 index 00000000000..437680a18fd --- /dev/null +++ b/core/testlib/README.md @@ -0,0 +1,3 @@ +This directory contains a synthetic JAR (`fakejar.jar`) that is needed for `org.testcontainers.utility.MountableFileTest`. + +The `create_fakejar.sh` script may be used to recreate it. \ No newline at end of file diff --git a/core/testlib/create_fakejar.sh b/core/testlib/create_fakejar.sh new file mode 100755 index 00000000000..e944cec94bc --- /dev/null +++ b/core/testlib/create_fakejar.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +rm fakejar.jar +zip -r fakejar.jar META-INF/ recursive/ +mv fakejar.jar repo/fakejar/fakejar/0/fakejar-0.jar \ No newline at end of file diff --git a/core/testlib/recursive/dir/content.txt b/core/testlib/recursive/dir/content.txt new file mode 100644 index 00000000000..d4e6444e225 --- /dev/null +++ b/core/testlib/recursive/dir/content.txt @@ -0,0 +1 @@ +This is example content to show recursive mounting of files from inside a JAR archive. \ No newline at end of file diff --git a/core/testlib/repo/fakejar/fakejar/0/fakejar-0.jar b/core/testlib/repo/fakejar/fakejar/0/fakejar-0.jar new file mode 100644 index 00000000000..9cd86a3c137 Binary files /dev/null and b/core/testlib/repo/fakejar/fakejar/0/fakejar-0.jar differ diff --git a/core/testlib/repo/fakejar/fakejar/0/fakejar-0.pom b/core/testlib/repo/fakejar/fakejar/0/fakejar-0.pom new file mode 100644 index 00000000000..dda792b1989 --- /dev/null +++ b/core/testlib/repo/fakejar/fakejar/0/fakejar-0.pom @@ -0,0 +1,9 @@ + + + 4.0.0 + fakejar + fakejar + 0 + POM was created from install:install-file + diff --git a/core/testlib/repo/fakejar/fakejar/maven-metadata-local.xml b/core/testlib/repo/fakejar/fakejar/maven-metadata-local.xml new file mode 100644 index 00000000000..cca5c37c2f1 --- /dev/null +++ b/core/testlib/repo/fakejar/fakejar/maven-metadata-local.xml @@ -0,0 +1,12 @@ + + + fakejar + fakejar + + 0 + + 0 + + 20170414082010 + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000000..bfb6bdc35fe --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3.7" + +services: + docs: + image: python:3.8 + command: sh -c "pip install -r requirements.txt && mkdocs serve -a 0.0.0.0:8000" + working_dir: /docs + volumes: + - ./:/docs + ports: + - 8000:8000 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md deleted file mode 100644 index e315796a9d1..00000000000 --- a/docs/ROADMAP.md +++ /dev/null @@ -1,25 +0,0 @@ -# Roadmap - -## Principles for the project - -Exact future features aren't set in stone, but here are the principles we should follow for this project: - -* Testcontainers is intended to utilize containers to, primarily, make integration testing of systems easier, quicker and more reliable. -* It's designed primarily to help developers. Make it easy to do the right thing, with sensible defaults, a fairly limited surface area to discover, and helpful log/error messages. -* It's not intended to be the solution to every problem under the sun. Don't try and solve every problem with this one library; if it takes a lot of work to incorporate a feature then perhaps it's the job of another tool. -* In line with the above, we should aim for feature completeness soon. -* Similarly, it should leverage good things that already exist (like reliable public Docker images) and be composable with other tools. -* That said, don't lock us in to more third-party components than absolutely necessary, and only use well maintained dependencies. -* It must be possible for a developer to use _or build_ this library with just minimal standard tools (Java JDK, Maven), and a local Docker environment. Do not use any non-public dependencies in the core lib or standard modules. (see the `proprietary-deps` maven profile for how we're dealing with the Oracle XE module that has a test-time dependency on the properitary Oracle JDBC driver JAR) - -## Future goals/anti-goals - -This list may cross over with the [issues list](https://github.com/testcontainers/testcontainers-java/issues) some times. - -* **Better documentation and log messages**. We need this. -* **Database state snapshotting**. Some projects have complex test data that is time consuming to reload. This means that either testing takes a very long time (test data has to be reloaded often), or tests aren't sufficiently independent (test data is not reloaded and therefore tests aren't independent). Testcontainers should provide a way to snapshot DB state and quickly reload that state. Docker [native Checkpoint/Restore In Userspace](http://blog.kubernetes.io/2015/07/how-did-quake-demo-from-dockercon-work.html) looks like the best way to accomplish this. -* **Support docker images pulled from private registries** -* **Support integration testing in other languages/environments**. .NET and NodeJS seem like sensible options here. -* **Allow a JUnit @Rule to point to a local `Dockerfile` rather than an imagename**. Perhaps. -* **Don't enter the build tools space** -* **Don't become involved in live run-time environments**. This is a tool for testing. Some features could technically be misused in a live environment, but this isn't something we should want to support. \ No newline at end of file diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md deleted file mode 100644 index 62f1d877a92..00000000000 --- a/docs/SUMMARY.md +++ /dev/null @@ -1,57 +0,0 @@ -# Summary - -* [Introduction](index.md) -* [Compatibility](compatibility.md) -* [Usage](usage.md) - * [Prerequisites](usage.md#prerequisites) - * [Docker environment discovery](usage.md#docker-environment-discovery) - * [Usage modes](usage.md#usage-modes) - * [Maven dependencies](usage.md#maven-dependencies) - * [Logging](usage.md#logging) - * [Running inside Docker](usage/inside_docker.md) - -## Generic containers - -* [Benefits](usage/generic_containers.md#benefits) -* [Example](usage/generic_containers.md#example) -* [Accessing a container from tests](usage/generic_containers.md#accessing-a-container-from-tests) -* [Options](usage/options.md) - * [Specifying image name](usage/options.md#image) - * [Exposing ports](usage/options.md#exposing-ports) - * [Environment variables](usage/options.md#environment-variables) - * [Command](usage/options.md#command) - * [Volume mapping](usage/options.md#volume-mapping) - * [Customizing the container](usage/options.md#customizing-the-container) - * [Startup timeout](usage/options.md#startup-timeout) - * [Following container output](usage/options.md#following-container-output) - * [Executing a command](usage/options.md#executing-a-command) - - -## Specialised container types - -* [Temporary database containers](usage/database_containers.md) - * [Benefits](usage/database_containers.md#benefits) - * [Examples and options](usage/database_containers.md#examples-and-options) - * [JUnit rule](usage/database_containers.md#junit-rule) - * [JDBC URL](usage/database_containers.md#jdbc-url) - * [Using an init script](usage/database_containers.md#using-an-init-script) - -* [Selenium WebDriver containers](usage/webdriver_containers.md) - * [Benefits](usage/webdriver_containers.md#benefits) - * [Example](usage/webdriver_containers.md#example) - * [Other browsers](usage/webdriver_containers.md#other-browsers) - * [Recording videos](usage/webdriver_containers.md#recording-videos) - -* [Docker Compose](usage/docker_compose.md) -* [Dockerfile containers](usage/dockerfile.md) -* [Windows support](usage/windows_support.md) - -## Examples -* [Selenium](https://github.com/testcontainers/testcontainers-java-examples/blob/master/selenium-container/src/test/java/SeleniumContainerTest.java) -* [Custom Redis container](https://github.com/testcontainers/testcontainers-java-examples/blob/master/redis-backed-cache/src/test/java/RedisBackedCacheTest.java) -* [Spring Boot testing with Redis](https://github.com/testcontainers/testcontainers-java-examples/tree/master/spring-boot/src/test/java/com/example) - -## -* [License](index.md#license) -* [Attributions](index.md#attributions) -* [Contributing](index.md#contributing) diff --git a/docs/_headers b/docs/_headers new file mode 100644 index 00000000000..e59f34a29e2 --- /dev/null +++ b/docs/_headers @@ -0,0 +1,2 @@ +/search/search_index.json + Access-Control-Allow-Origin: * diff --git a/docs/_redirects b/docs/_redirects new file mode 100644 index 00000000000..d3c61aba618 --- /dev/null +++ b/docs/_redirects @@ -0,0 +1,22 @@ +# Each redirect rule must be listed on a separate line, with the original path followed by the new path or URL. + +/usage/docker_compose.html /modules/docker_compose/ +/usage/generic_containers.html /features/creating_container/ +/usage/windows_support.html /supported_docker_environment/windows/ +/usage/dockerfile.html /features/creating_images/ +/usage/inside_docker.html /supported_docker_environment/continuous_integration/dind_patterns/ +/usage/webdriver_containers.html /modules/webdriver_containers/ +/usage/properties.html /features/configuration/ +/usage/kafka_containers.html /modules/kafka/ +/usage/elasticsearch_container.html /modules/elasticsearch/ +/usage/database_containers.html /modules/databases/ +/usage/neo4j_container.html /modules/databases/neo4j/ +/compatibility.html /supported_docker_environment/ +/on_failure.html /error_missing_container_runtime_environment + +# No great 1:1 mapping exists for the following, so redirect to somewhere where at least a sensible sidebar will be shown + +/usage/options.html /features/creating_container/ +/ci/ci.html /supported_docker_environment/ +/usage.html / + diff --git a/docs/compatibility.md b/docs/compatibility.md deleted file mode 100644 index 79ebbd9d24a..00000000000 --- a/docs/compatibility.md +++ /dev/null @@ -1,12 +0,0 @@ -# Compatibility - -| Host Operating System / Environment | Minimum recommended docker versions | Known issues / tips | -|-------------------------------------|-----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Linux - general | Docker v1.10 | | -| Linux - Travis CI | Docker v1.10 | See [example .travis.yml](https://raw.githubusercontent.com/testcontainers/testcontainers-java/master/.travis.yml) for baseline Travis CI configuration | -| Linux - Circle CI (LXC driver) | Docker v1.9.1 | The `exec` feature is not compatible with Circle CI. See [example circle.yml](https://raw.githubusercontent.com/testcontainers/testcontainers-java/master/circle.yml) for baseline CircleCI configuration | -| Linux - within a Docker container | Docker v1.12 | See [Running inside Docker](usage/inside_docker.md) for the detailed configuration | -| Mac OS X - Docker Toolbox | Docker Machine v0.8.0 | | -| Mac OS X - Docker for Mac | 1.12.0 | *Support is best-efforts at present*. `getTestHostIpAddress()` is [not currently supported](https://github.com/testcontainers/testcontainers-java/issues/166) due to limitations in Docker for Mac. | -| Windows - Docker Toolbox | | *Support is limited at present and this is not currently tested on a regular basis*. | -| Windows - Docker for Windows | | *Supported is limited at present (Docker Compose tests are failing) and not currently tested on a regular basis.* | diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 00000000000..1cad43877cf --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,106 @@ +# Contributing + +* Star the project on [GitHub](https://github.com/testcontainers/testcontainers-java) and help spread the word :) +* Join our [Slack workspace](http://slack.testcontainers.org) +* [Start a discussion](https://github.com/testcontainers/testcontainers-java/discussions) if you have an idea, find a possible bug or have a general question. +* Contribute improvements or fixes using a [Pull Request](https://github.com/testcontainers/testcontainers-java/pulls). If you're going to contribute, thank you! Please just be sure to: + * discuss with the authors prior to doing anything big. + * follow the style, naming and structure conventions of the rest of the project. + * make commits atomic and easy to merge. + * when updating documentation, please see [our guidance for documentation contributions](contributing_docs.md). + * apply format running `./gradlew spotlessApply` (this requires [Node.js](https://nodejs.org/) to be installed on your machine, one of the [package managers](https://nodejs.org/en/download/package-manager/) might be handy) + * verify all tests are passing. Build the project with `./gradlew check` to do this. + **N.B.** Gradle's Build Cache is enabled by default, but you can add `--no-build-cache` flag to disable it. + +## Contributing new modules + +We often receive proposals (or fully formed PRs) for new modules. +We're very happy to have contributions, but new modules require specific extra care. We want to balance: + +* Usefulness of the module. +* Our ability to support the module in the future, potentially after contributors have moved on. +* Contributors time, so that nobody puts in wasted effort. + +### Does it need to be a module? + +*N.B. this is not a perfect list - please always reach out to us before starting on a module contribution!* + +* Does the module enable use of Testcontainers with a popular or rapidly growing technology? +* Does the module 'add value' beyond a `GenericContainer` code snippet/example? e.g. + * does it neatly encapsulate a difficult problem of running the program in a container? + * does it add technology-specific [wait strategies](features/startup_and_waits.md)? + * does it enable straightforward usage of client libraries? + +If the answers to the above are all yes, then a new module may be a good approach. + +Otherwise, it is entirely possible for you to: + +* publish a code snippet +* contribute an example to the Testcontainers repo +* publish your own third party library + +In any case, please contact us to help validate your proposal! + +### Checklist + +*Suggestion: copy and paste this list into PRs for new modules.* + +Every item on this list will require judgement by the Testcontainers core maintainers. Exceptions will sometimes be possible; items with `should` are more likely to be negotiable than those items with `must`. + +#### Default docker image + +- [ ] Should be a Docker Hub official image, or published by a reputable source (ideally the company or organisation that officially supports the technology) +- [ ] Should have a verifiable open source Dockerfile and a way to view the history of changes +- [ ] MUST show general good practices regarding container image tagging - e.g. we do not use `latest` tags, and we do not use tags that may be mutated in the future +- [ ] MUST be legal for Testcontainers developers and Testcontainers users to pull and use. Mechanisms exist to allow EULA acceptance to be signalled, but images that can be used without a licence are greatly preferred. + +#### Module dependencies + +- [ ] The module should use as few dependencies as possible, +- [ ] Regarding libraries, either: + - they should be `compileOnly` if they are likely to already be on the classpath for users' tests (e.g. client libraries or drivers) + - they can be `implementation` (and thus transitive dependencies) if they are very unlikely to conflict with users' dependencies. +- [ ] If client libraries are used to test or use the module, these MUST be legal for Testcontainers developers and Testcontainers users to download and use. + +#### API (e.g. `MyModuleContainer` class) + +- [ ] Favour doing the right thing, and least surprising thing, by default +- [ ] Ensure that method and parameter names are easy to understand. Many users will ignore documentation, so IDE-based substitutes (autocompletion and Javadocs) should be intuitive. +- [ ] The module's public API should only handle standard JDK data types and MUST not expose data types that come from `compileOnly` dependencies. This is to reduce the risk of compatibility problems with future versions of third party libraries. +#### Documentation + +- [ ] Every module MUST have a dedicated documentation page containing: + - [ ] A high level overview + - [ ] A usage example + - [ ] If appropriate, basic API documentation or further usage guidelines + - [ ] Dependency information + - [ ] Acknowledgements, if appropriate +- [ ] Consider that many users will not read the documentation pages - even if the first person to add it to a project does, people reading/updating the code in the future may not. Try and avoid the need for critical knowledge that is only present in documentation. + + + +### Incubating modules + +We have a policy of marking new modules as 'incubating' so that we can evaluate its maintainability and usability traits over a longer period of time. +We currently believe 3 months is a fair period of time, but may change this. + +New modules should have the following warning at the top of their documentation pages: + +!!! note + This module is INCUBATING. While it is ready for use and operational in the current version of Testcontainers, it is possible that it may receive breaking changes in the future. See [our contributing guidelines](/contributing/#incubating-modules) for more information on our incubating modules policy. + +We will evaluate incubating modules periodically, and remove the label when appropriate. + + +## Combining Dependabot PRs + +Since we generally get a lot of Dependabot PRs, we regularly combine them into single commits. +For this, we are using the [gh-combine-prs](https://github.com/rnorth/gh-combine-prs) extension for [GitHub CLI](https://cli.github.com/). + +The whole process is as follows: + +1. Check that all open Dependabot PRs did succeed their build. If they did not succeed, trigger a rerun if the cause were external factors or else document the reason if obvious. +2. Run the extension from an up-to-date local `main` branch: `gh combine-prs --query "author:app/dependabot"` +3. Merge conflicts might appear. Just ignore them, we will get those PRs in a future run. +4. Once the build of the combined PR did succeed, temporarily enable merge commits and merge the PR using a merge commit through the GitHub UI. +5. After the merge, disable merge commits again. diff --git a/docs/contributing_docs.md b/docs/contributing_docs.md new file mode 100644 index 00000000000..51cb5aae780 --- /dev/null +++ b/docs/contributing_docs.md @@ -0,0 +1,109 @@ +# Contributing to documentation + +The Testcontainers for Java documentation is a static site built with [MkDocs](https://www.mkdocs.org/). +We use the [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/) theme, which offers a number of useful extensions to MkDocs. + +In addition we use a [custom plugin](https://github.com/rnorth/mkdocs-codeinclude-plugin) for inclusion of code snippets. + +We publish our documentation using Netlify. + +## Previewing rendered content + +### Using Docker locally + +The root of the project contains a `docker-compose.yml` file. Simply run `docker-compose up` and then access the docs at [http://localhost:8000](http://localhost:8000). + +### Using Python locally + +* Ensure that you have Python 3.8.0 or higher. +* Set up a virtualenv and run `pip install -r requirements.txt` in the `testcontainers-java` root directory. +* Once Python dependencies have been installed, run `mkdocs serve` to start a local auto-updating MkDocs server. + +### PR Preview deployments + +Note that documentation for pull requests will automatically be published by Netlify as 'deploy previews'. +These deployment previews can be accessed via the `deploy/netlify` check that appears for each pull request. + +## Codeincludes + +The Gradle project under `docs/examples` is intended to hold compilable, runnable example code that can be included as +snippets into the documentation at build-time. + +As a result, we can have more confidence that code samples shown in the documentation is valid. + +We use a custom plugin for MkDocs to include snippets into our docs. + +A codeinclude block will resemble a regular markdown link surrounded by a pair of XML comments, e.g.: + + + +

<!--codeinclude-->
+[Human readable title for snippet](./relative_path_to_example_code.java) targeting_expression
+<!--/codeinclude-->
+
+ +Where `targeting_expression` could be: + +* `block:someString` or +* `inside_block:someString` + +If these are provided, the macro will seek out any line containing the token `someString` and grab the next curly brace +delimited block that it finds. `block` will grab the starting line and closing brace, whereas `inside_block` will omit +these. + +e.g., given: +```java + +public class FooService { + + public void doFoo() { + foo.doSomething(); + } + + ... + +``` + +If we use `block:doFoo` as our targeting expression, we will have the following content included into our page: + +```java +public void doFoo() { + foo.doSomething(); +} +``` + +Whereas using `inside_block:doFoo` we would just have the inner content of the method included: + +```java +foo.doSomething(); +``` + +Note that: + +* Any code included will have its indentation reduced +* Every line in the source file will be searched for an instance of the token (e.g. `doFoo`). If more than one line + includes that token, then potentially more than one block could be targeted for inclusion. It is advisable to use a + specific, unique token to avoid unexpected behaviour. + +When we wish to include a section of code that does not naturally appear within braces, we can simply insert our token, +with matching braces, in a comment. +While a little ugly, this has the benefit of working in any context and is easy to understand. +For example: + +```java +public class FooService { + + public void boringMethod() { + doSomethingBoring(); + + // doFoo { + doTheThingThatWeActuallyWantToShow(); + // } + } + + +``` diff --git a/docs/css/_normalize.less b/docs/css/_normalize.less deleted file mode 100755 index c2de8df9426..00000000000 --- a/docs/css/_normalize.less +++ /dev/null @@ -1,406 +0,0 @@ -/*! normalize.css v2.1.3 | MIT License | git.io/normalize */ - -/* ========================================================================== - HTML5 display definitions - ========================================================================== */ - -/** - * Correct `block` display not defined in IE 8/9. - */ - -article, -aside, -details, -figcaption, -figure, -footer, -header, -hgroup, -main, -nav, -section, -summary { - display: block; -} - -/** - * Correct `inline-block` display not defined in IE 8/9. - */ - -audio, -canvas, -video { - display: inline-block; -} - -/** - * Prevent modern browsers from displaying `audio` without controls. - * Remove excess height in iOS 5 devices. - */ - -audio:not([controls]) { - display: none; - height: 0; -} - -/** - * Address `[hidden]` styling not present in IE 8/9. - * Hide the `template` element in IE, Safari, and Firefox < 22. - */ - -[hidden], -template { - display: none; -} - -/* ========================================================================== - Base - ========================================================================== */ - -/** - * 1. Set default font family to sans-serif. - * 2. Prevent iOS text size adjust after orientation change, without disabling - * user zoom. - */ - -html { - font-family: sans-serif; /* 1 */ - -ms-text-size-adjust: 100%; /* 2 */ - -webkit-text-size-adjust: 100%; /* 2 */ -} - -/** - * Remove default margin. - */ - -body { - margin: 0; -} - -/* ========================================================================== - Links - ========================================================================== */ - -/** - * Remove the gray background color from active links in IE 10. - */ - -a { - background: transparent; -} - -/** - * Address `outline` inconsistency between Chrome and other browsers. - */ - -a:focus { - outline: thin dotted; -} - -/** - * Improve readability when focused and also mouse hovered in all browsers. - */ - -a:active, -a:hover { - outline: 0; -} - -/* ========================================================================== - Typography - ========================================================================== */ - -/** - * Address variable `h1` font-size and margin within `section` and `article` - * contexts in Firefox 4+, Safari 5, and Chrome. - */ - -h1 { - font-size: 2em; - margin: 0.67em 0; -} - -/** - * Address styling not present in IE 8/9, Safari 5, and Chrome. - */ - -abbr[title] { - border-bottom: 1px dotted; -} - -/** - * Address style set to `bolder` in Firefox 4+, Safari 5, and Chrome. - */ - -b, -strong { - font-weight: bold; -} - -/** - * Address styling not present in Safari 5 and Chrome. - */ - -dfn { - font-style: italic; -} - -/** - * Address differences between Firefox and other browsers. - */ - -hr { - -moz-box-sizing: content-box; - box-sizing: content-box; - height: 0; -} - -/** - * Address styling not present in IE 8/9. - */ - -mark { - background: #ff0; - color: #000; -} - -/** - * Correct font family set oddly in Safari 5 and Chrome. - */ - -code, -kbd, -pre, -samp { - font-family: monospace, serif; - font-size: 1em; -} - -/** - * Improve readability of pre-formatted text in all browsers. - */ - -pre { - white-space: pre-wrap; -} - -/** - * Set consistent quote types. - */ - -q { - quotes: "\201C" "\201D" "\2018" "\2019"; -} - -/** - * Address inconsistent and variable font size in all browsers. - */ - -small { - font-size: 80%; -} - -/** - * Prevent `sub` and `sup` affecting `line-height` in all browsers. - */ - -sub, -sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} - -sup { - top: -0.5em; -} - -sub { - bottom: -0.25em; -} - -/* ========================================================================== - Embedded content - ========================================================================== */ - -/** - * Remove border when inside `a` element in IE 8/9. - */ - -img { - border: 0; -} - -/** - * Correct overflow displayed oddly in IE 9. - */ - -svg:not(:root) { - overflow: hidden; -} - -/* ========================================================================== - Figures - ========================================================================== */ - -/** - * Address margin not present in IE 8/9 and Safari 5. - */ - -figure { - margin: 0; -} - -/* ========================================================================== - Forms - ========================================================================== */ - -/** - * Define consistent border, margin, and padding. - */ - -fieldset { - border: 1px solid #c0c0c0; - margin: 0 2px; - padding: 0.35em 0.625em 0.75em; -} - -/** - * 1. Correct `color` not being inherited in IE 8/9. - * 2. Remove padding so people aren't caught out if they zero out fieldsets. - */ - -legend { - border: 0; /* 1 */ - padding: 0; /* 2 */ -} - -/** - * 1. Correct font family not being inherited in all browsers. - * 2. Correct font size not being inherited in all browsers. - * 3. Address margins set differently in Firefox 4+, Safari 5, and Chrome. - */ - -button, -input, -select, -textarea { - font-family: inherit; /* 1 */ - font-size: 100%; /* 2 */ - margin: 0; /* 3 */ -} - -/** - * Address Firefox 4+ setting `line-height` on `input` using `!important` in - * the UA stylesheet. - */ - -button, -input { - line-height: normal; -} - -/** - * Address inconsistent `text-transform` inheritance for `button` and `select`. - * All other form control elements do not inherit `text-transform` values. - * Correct `button` style inheritance in Chrome, Safari 5+, and IE 8+. - * Correct `select` style inheritance in Firefox 4+ and Opera. - */ - -button, -select { - text-transform: none; -} - -/** - * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` - * and `video` controls. - * 2. Correct inability to style clickable `input` types in iOS. - * 3. Improve usability and consistency of cursor style between image-type - * `input` and others. - */ - -button, -html input[type="button"], /* 1 */ -input[type="reset"], -input[type="submit"] { - -webkit-appearance: button; /* 2 */ - cursor: pointer; /* 3 */ -} - -/** - * Re-set default cursor for disabled elements. - */ - -button[disabled], -html input[disabled] { - cursor: default; -} - -/** - * 1. Address box sizing set to `content-box` in IE 8/9/10. - * 2. Remove excess padding in IE 8/9/10. - */ - -input[type="checkbox"], -input[type="radio"] { - box-sizing: border-box; /* 1 */ - padding: 0; /* 2 */ -} - -/** - * 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome. - * 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome - * (include `-moz` to future-proof). - */ - -input[type="search"] { - -webkit-appearance: textfield; /* 1 */ - -moz-box-sizing: content-box; - -webkit-box-sizing: content-box; /* 2 */ - box-sizing: content-box; -} - -/** - * Remove inner padding and search cancel button in Safari 5 and Chrome - * on OS X. - */ - -input[type="search"]::-webkit-search-cancel-button, -input[type="search"]::-webkit-search-decoration { - -webkit-appearance: none; -} - -/** - * Remove inner padding and border in Firefox 4+. - */ - -button::-moz-focus-inner, -input::-moz-focus-inner { - border: 0; - padding: 0; -} - -/** - * 1. Remove default vertical scrollbar in IE 8/9. - * 2. Improve readability and alignment in all browsers. - */ - -textarea { - overflow: auto; /* 1 */ - vertical-align: top; /* 2 */ -} - -/* ========================================================================== - Tables - ========================================================================== */ - -/** - * Remove most spacing between table cells. - */ - -table { - border-collapse: collapse; - border-spacing: 0; -} diff --git a/docs/css/extra.css b/docs/css/extra.css new file mode 100644 index 00000000000..ff780cb25ea --- /dev/null +++ b/docs/css/extra.css @@ -0,0 +1,128 @@ +h1, h2, h3, h4, h5, h6 { + font-family: 'Rubik', sans-serif; +} + +[data-md-color-scheme="testcontainers"] { + --md-primary-fg-color: #00bac2; + --md-accent-fg-color: #361E5B; + --md-typeset-a-color: #0C94AA; + --md-primary-fg-color--dark: #291A3F; + --md-default-fg-color--lightest: #F2F4FE; + --md-footer-fg-color: #361E5B; + --md-footer-fg-color--light: #746C8F; + --md-footer-fg-color--lighter: #C3BEDE; + --md-footer-bg-color: #F7F9FD; + --md-footer-bg-color--dark: #F7F9FD; +} + +.card-grid { + display: grid; + gap: 10px; +} + +.tc-version { + font-size: 1.1em; + text-align: center; + margin: 0; +} + +@media (min-width: 680px) { + .card-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +body .card-grid-item { + display: flex; + align-items: center; + gap: 20px; + border: 1px solid #C3BEDE; + border-radius: 6px; + padding: 16px; + font-weight: 600; + color: #9991B5; + background: #F2F4FE; +} + +body .card-grid-item:hover, +body .card-grid-item:focus { + color: #9991B5; +} + +.card-grid-item[href] { + color: var(--md-primary-fg-color--dark); + background: transparent; +} + +.card-grid-item[href]:hover, +.card-grid-item[href]:focus { + background: #F2F4FE; + color: var(--md-primary-fg-color--dark); +} + +.community-callout-wrapper { + padding: 30px 10px 0 10px; +} + +.community-callout { + color: #F2F4FE; + background: linear-gradient(10.88deg, rgba(102, 56, 242, 0.4) 9.56%, #6638F2 100%), #291A3F; + box-shadow: 0px 20px 45px rgba(#9991B5, 0.75); + border-radius: 10px; + padding: 20px; +} + +.community-callout h2 { + font-size: 1.15em; + margin: 0 0 20px 0; + color: #F2F4FE; + text-align: center; +} + +.community-callout ul { + list-style: none; + padding: 0; + display: flex; + justify-content: space-between; + gap: 10px; + margin-top: 20px; + margin-bottom: 0; +} + +.community-callout a { + transition: opacity 0.2s ease; +} + +.community-callout a:hover { + opacity: 0.5; +} + +.community-callout a img { + height: 1.75em; + width: auto; + aspect-ratio: 1; +} + +@media (min-width: 1220px) { + .community-callout-wrapper { + padding: 40px 0 0; + } + + .community-callout h2 { + font-size: 1.25em; + } + + .community-callout a img { + height: 2em; + } +} + +@media (min-width: 1600px) { + .community-callout h2 { + font-size: 1.15em; + } + + .community-callout a img { + height: 1.75em; + } +} \ No newline at end of file diff --git a/docs/css/style.css b/docs/css/style.css deleted file mode 100644 index 2b078367dec..00000000000 --- a/docs/css/style.css +++ /dev/null @@ -1,513 +0,0 @@ -/*! normalize.css v2.1.3 | MIT License | git.io/normalize */ -/* ========================================================================== - HTML5 display definitions - ========================================================================== */ -/** - * Correct `block` display not defined in IE 8/9. - */ -@import url(//fonts.googleapis.com/css?family=Source+Serif+Pro); -@import url(//fonts.googleapis.com/css?family=Source+Code+Pro); -article, -aside, -details, -figcaption, -figure, -footer, -header, -hgroup, -main, -nav, -section, -summary { - display: block; -} -/** - * Correct `inline-block` display not defined in IE 8/9. - */ -audio, -canvas, -video { - display: inline-block; -} -/** - * Prevent modern browsers from displaying `audio` without controls. - * Remove excess height in iOS 5 devices. - */ -audio:not([controls]) { - display: none; - height: 0; -} -/** - * Address `[hidden]` styling not present in IE 8/9. - * Hide the `template` element in IE, Safari, and Firefox < 22. - */ -[hidden], -template { - display: none; -} -/* ========================================================================== - Base - ========================================================================== */ -/** - * 1. Set default font family to sans-serif. - * 2. Prevent iOS text size adjust after orientation change, without disabling - * user zoom. - */ -html { - font-family: sans-serif; - /* 1 */ - -ms-text-size-adjust: 100%; - /* 2 */ - -webkit-text-size-adjust: 100%; - /* 2 */ -} -/** - * Remove default margin. - */ -body { - margin: 0; -} -/* ========================================================================== - Links - ========================================================================== */ -/** - * Remove the gray background color from active links in IE 10. - */ -a { - background: transparent; -} -/** - * Address `outline` inconsistency between Chrome and other browsers. - */ -a:focus { - outline: thin dotted; -} -/** - * Improve readability when focused and also mouse hovered in all browsers. - */ -a:active, -a:hover { - outline: 0; -} -/* ========================================================================== - Typography - ========================================================================== */ -/** - * Address variable `h1` font-size and margin within `section` and `article` - * contexts in Firefox 4+, Safari 5, and Chrome. - */ -h1 { - font-size: 2em; - margin: 0.67em 0; -} -/** - * Address styling not present in IE 8/9, Safari 5, and Chrome. - */ -abbr[title] { - border-bottom: 1px dotted; -} -/** - * Address style set to `bolder` in Firefox 4+, Safari 5, and Chrome. - */ -b, -strong { - font-weight: bold; -} -/** - * Address styling not present in Safari 5 and Chrome. - */ -dfn { - font-style: italic; -} -/** - * Address differences between Firefox and other browsers. - */ -hr { - -moz-box-sizing: content-box; - box-sizing: content-box; - height: 0; -} -/** - * Address styling not present in IE 8/9. - */ -mark { - background: #ff0; - color: #000; -} -/** - * Correct font family set oddly in Safari 5 and Chrome. - */ -code, -kbd, -pre, -samp { - font-family: monospace, serif; - font-size: 1em; -} -/** - * Improve readability of pre-formatted text in all browsers. - */ -pre { - white-space: pre-wrap; -} -/** - * Set consistent quote types. - */ -q { - quotes: "\201C" "\201D" "\2018" "\2019"; -} -/** - * Address inconsistent and variable font size in all browsers. - */ -small { - font-size: 80%; -} -/** - * Prevent `sub` and `sup` affecting `line-height` in all browsers. - */ -sub, -sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} -sup { - top: -0.5em; -} -sub { - bottom: -0.25em; -} -/* ========================================================================== - Embedded content - ========================================================================== */ -/** - * Remove border when inside `a` element in IE 8/9. - */ -img { - border: 0; -} -/** - * Correct overflow displayed oddly in IE 9. - */ -svg:not(:root) { - overflow: hidden; -} -/* ========================================================================== - Figures - ========================================================================== */ -/** - * Address margin not present in IE 8/9 and Safari 5. - */ -figure { - margin: 0; -} -/* ========================================================================== - Forms - ========================================================================== */ -/** - * Define consistent border, margin, and padding. - */ -fieldset { - border: 1px solid #c0c0c0; - margin: 0 2px; - padding: 0.35em 0.625em 0.75em; -} -/** - * 1. Correct `color` not being inherited in IE 8/9. - * 2. Remove padding so people aren't caught out if they zero out fieldsets. - */ -legend { - border: 0; - /* 1 */ - padding: 0; - /* 2 */ -} -/** - * 1. Correct font family not being inherited in all browsers. - * 2. Correct font size not being inherited in all browsers. - * 3. Address margins set differently in Firefox 4+, Safari 5, and Chrome. - */ -button, -input, -select, -textarea { - font-family: inherit; - /* 1 */ - font-size: 100%; - /* 2 */ - margin: 0; - /* 3 */ -} -/** - * Address Firefox 4+ setting `line-height` on `input` using `!important` in - * the UA stylesheet. - */ -button, -input { - line-height: normal; -} -/** - * Address inconsistent `text-transform` inheritance for `button` and `select`. - * All other form control elements do not inherit `text-transform` values. - * Correct `button` style inheritance in Chrome, Safari 5+, and IE 8+. - * Correct `select` style inheritance in Firefox 4+ and Opera. - */ -button, -select { - text-transform: none; -} -/** - * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` - * and `video` controls. - * 2. Correct inability to style clickable `input` types in iOS. - * 3. Improve usability and consistency of cursor style between image-type - * `input` and others. - */ -button, -html input[type="button"], -input[type="reset"], -input[type="submit"] { - -webkit-appearance: button; - /* 2 */ - cursor: pointer; - /* 3 */ -} -/** - * Re-set default cursor for disabled elements. - */ -button[disabled], -html input[disabled] { - cursor: default; -} -/** - * 1. Address box sizing set to `content-box` in IE 8/9/10. - * 2. Remove excess padding in IE 8/9/10. - */ -input[type="checkbox"], -input[type="radio"] { - box-sizing: border-box; - /* 1 */ - padding: 0; - /* 2 */ -} -/** - * 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome. - * 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome - * (include `-moz` to future-proof). - */ -input[type="search"] { - -webkit-appearance: textfield; - /* 1 */ - -moz-box-sizing: content-box; - -webkit-box-sizing: content-box; - /* 2 */ - box-sizing: content-box; -} -/** - * Remove inner padding and search cancel button in Safari 5 and Chrome - * on OS X. - */ -input[type="search"]::-webkit-search-cancel-button, -input[type="search"]::-webkit-search-decoration { - -webkit-appearance: none; -} -/** - * Remove inner padding and border in Firefox 4+. - */ -button::-moz-focus-inner, -input::-moz-focus-inner { - border: 0; - padding: 0; -} -/** - * 1. Remove default vertical scrollbar in IE 8/9. - * 2. Improve readability and alignment in all browsers. - */ -textarea { - overflow: auto; - /* 1 */ - vertical-align: top; - /* 2 */ -} -/* ========================================================================== - Tables - ========================================================================== */ -/** - * Remove most spacing between table cells. - */ -table { - border-collapse: collapse; - border-spacing: 0; -} -body { - background-color: #fff; - margin: 0 auto; - height: 100%; - zoom: 1; - letter-spacing: normal; - word-spacing: normal; - color: #111; - font: 20px "Source Sans Pro", "Calibri", "Helvetica", "Arial", "Freesans", "Clean", sans-serif; - line-height: 1.4; - -webkit-font-smoothing: antialiased; - width: 960px; - text-align: left; - text-rendering: optimizelegibility; - font-variant-ligatures: common-ligatures discretionary-ligatures; - font-feature-settings: "kern", "onum", "lnum" off; -} -@media screen and (max-width: 659px) { - body { - width: 100%; - } -} -@media screen and (min-width: 660px) { - body { - width: 660px; - margin: 0 auto; - } -} -h1, -h2 { - border-top: 2px solid #ddd; -} -h1, -h2, -h3 { - margin-top: 3em; - padding-top: 0.5em; - margin-bottom: 0.3em; -} -h4, -h4, -h5 { - margin-top: 2em; -} -h1 + h2, -h2 + h3, -h3 + h4 { - border-top: none; - margin-top: 2em; -} -h1, -h2, -h3, -h4, -h5, -h6 { - font-family: "Source Serif Pro", serif; -} -h1 a, -h2 a, -h3 a, -h4 a, -h5 a, -h6 a { - text-decoration: none; - color: #000; -} -h1 a:hover, -h2 a:hover, -h3 a:hover, -h4 a:hover, -h5 a:hover, -h6 a:hover { - text-underline-mode: dotted; - text-underline: dotted; -} -h1 a:hover::after, -h2 a:hover::after, -h3 a:hover::after, -h4 a:hover::after, -h5 a:hover::after, -h6 a:hover::after { - font: 80% "Source Sans Pro", "Calibri", "Helvetica", "Arial", "Freesans", "Clean", sans-serif; - content: "¶"; - margin-left: 1em; - color: #ccc; - text-decoration: none; -} -h1 .gistid, -h2 .gistid, -h3 .gistid, -h4 .gistid, -h5 .gistid, -h6 .gistid { - font-size: 20%; -} -p, -li, -dd { - line-height: 130%; - text-align: justify; -} -img { - max-width: 100%; -} -a { - text-decoration: none; - color: #46c; -} -code { - background-color: #f6f6f6; - font: 90% "Source Code Pro", "Monaco", "DejaVu Sans Mono", "Courier New", monospace; - padding: 2px 3px; - line-height: 150%; -} -pre { - background-color: #f6f6f6; - border: 1px solid #dddddd; - padding: 3px; - display: block; - font: 70% "Source Code Pro", "Monaco", "DejaVu Sans Mono", "Courier New", monospace; -} -pre code { - line-height: 140%; -} -blockquote { - border-left: 4px solid #ddd; - padding-left: 1em; - font-style: italic; -} -ul > li { - margin-left: -2em; -} -ul > li li { - margin-left: 0; -} -ul { - margin-bottom: 3em; -} -li.task-list-item { - list-style: none; -} -li.task-list-item input[type=checkbox] { - margin-right: 0.5em; -} -table { - margin: 2em 0 3em 0; - border-bottom: 1px solid #dddddd; - font-feature-settings: "tnum", "pnum" off; -} -table tr th { - padding: 5px 2px; - border-bottom: 1px solid #dddddd; - text-align: left; - font-family: "Source Serif Pro", serif; -} -table tr td { - padding: 5px 2px; -} -table tr { - border-bottom: 0.5px solid #e9e9e9; -} -h1#gistid { - font-size: 60%; - margin: 0px; - color: #ccc; - border-top: none; -} -h1#gistid a { - color: #ccc; -} diff --git a/docs/css/style.less b/docs/css/style.less deleted file mode 100644 index f9adf7171d5..00000000000 --- a/docs/css/style.less +++ /dev/null @@ -1,186 +0,0 @@ -@import "_normalize.less"; -// vars -@import url(//fonts.googleapis.com/css?family=Source+Serif+Pro); -@import url(//fonts.googleapis.com/css?family=Source+Code+Pro); - -@sans-font: "Source Sans Pro", "Calibri", "Helvetica", "Arial", "Freesans", "Clean", sans-serif; -@serif-font: "Source Serif Pro", serif; -@monospace-font: "Source Code Pro", "Monaco", "DejaVu Sans Mono", "Courier New", monospace; - -@text-color: #111; -@heading-color: #000; -@link-color: #46c; - -@code-bg-color: #f6f6f6; -@code-border-color: darken(@code-bg-color, 10%); -@table-row-color: #f6f6f6; - -@faint-color: #ccc; - -// layout -body { - background-color: #fff; - margin: 0 auto; - height: 100%; - zoom: 1; - letter-spacing: normal; - word-spacing: normal; - color: @text-color; - font: 20px @sans-font; - line-height: 1.4; - -webkit-font-smoothing: antialiased; - width: 960px; - text-align: left; - text-rendering: optimizelegibility; - font-variant-ligatures: common-ligatures discretionary-ligatures; - font-feature-settings: "kern", "onum", "lnum" off; -} - -@media screen and (max-width: 659px) { - body { - width: 100%; - } -} - -@media screen and (min-width: 660px) { - body { - width: 660px; - margin: 0 auto; - } -} - -h1, h2 { - border-top: 2px solid #ddd; -} - -h1, h2, h3 { - margin-top: 3em; - padding-top: 0.5em; - margin-bottom: 0.3em; -} - -h4, h4, h5 { - margin-top: 2em; -} - -h1 + h2, h2 + h3, h3 + h4 { - border-top: none; - margin-top: 2em; -} - -h1, h2, h3, h4, h5, h6 { - font-family: @serif-font; - - a { - text-decoration: none; - color: @heading-color; - - &:hover { - text-underline-mode: dotted; - text-underline: dotted; - } - - &:hover::after { - font: 80% @sans-font; - content: "¶"; - margin-left: 1em; - color: @faint-color; - text-decoration: none; - } - } - - .gistid { - font-size: 20%; - } -} - -p, li, dd { - line-height: 130%; - text-align: justify; -} - -img { - max-width: 100%; -} - -a { - text-decoration: none; - color: @link-color; -} - -code { - background-color: @code-bg-color; - font: 90% @monospace-font; - padding: 2px 3px; - line-height: 150%; -} - -pre { - background-color: @code-bg-color; - border: 1px solid @code-border-color; - padding: 3px; - display: block; - font: 70% @monospace-font; - - code { - line-height: 140%; - } -} - -blockquote { - border-left: 4px solid #ddd; - padding-left: 1em; - font-style: italic; -} - -ul > li { - margin-left: -2em; - li { - margin-left: 0; - } -} - -ul { - margin-bottom: 3em; -} - -li.task-list-item { - list-style: none; - input[type=checkbox] { - margin-right: 0.5em; - } -} - -table { - margin: 2em 0 3em 0; - - border-bottom: 1px solid @code-border-color; - - font-feature-settings: "tnum", "pnum" off; - - tr th { - padding: 5px 2px; - border-bottom: 1px solid @code-border-color; - text-align: left; - font-family: @serif-font; - } - - tr td { - padding: 5px 2px; - } - - tr { - border-bottom: 0.5px solid lighten(@code-border-color, 5%); - } -} - -h1#gistid { - font-size: 60%; - margin: 0px; - color: @faint-color; - border-top: none; - - a { - color: @faint-color; - } -} \ No newline at end of file diff --git a/docs/css/tc-header.css b/docs/css/tc-header.css new file mode 100644 index 00000000000..de78d636e9c --- /dev/null +++ b/docs/css/tc-header.css @@ -0,0 +1,389 @@ + +:root { + --color-catskill: #F2F4FE; + --color-catskill-45: rgba(242, 244, 254, 0.45); + --color-mist: #E7EAFB; + --color-fog: #C3C7E6; + --color-smoke: #9991B5; + --color-smoke-75: rgba(153, 145, 181, 0.75); + --color-storm: #746C8F; + --color-topaz: #00BAC2; + --color-pacific: #17A6B2; + --color-teal: #027F9E; + --color-eggplant: #291A3F; + --color-plum: #361E5B; + +} + +#site-header { + color: var(--color-storm); + background: #fff; + font-family: 'Rubik', Arial, Helvetica, sans-serif; + font-size: 12px; + line-height: 1.5; + position: relative; + width: 100%; + z-index: 4; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 20px; +} + +body.tc-header-active #site-header { + z-index: 5; +} + +#site-header .brand { + display: flex; + justify-content: space-between; + gap: 20px; + width: 100%; +} + +#site-header .logo { + display: flex; +} + +#site-header .logo img, +#site-header .logo svg { + height: 30px; + width: auto; + max-width: 100%; +} + +#site-header #mobile-menu-toggle { + background: none; + border: none; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + color: var(--color-eggplant); + padding: 0; + margin: 0; + font-weight: 500; +} + +body.mobile-menu #site-header #mobile-menu-toggle { + color: var(--color-topaz); +} + +#site-header ul { + list-style: none; + padding: 0; + margin: 0; +} + +#site-header nav { + display: none; +} + +#site-header .menu-item { + display: flex; +} + +#site-header .menu-item button, +#site-header .menu-item a { + min-height: 30px; + display: flex; + gap: 6px; + align-items: center; + border: none; + background: none; + cursor: pointer; + padding: 0; + font-weight: 500; + color: var(--color-eggplant); + text-decoration: none; + font-size: 14px; + transition: color 0.2s ease; + white-space: nowrap; +} + +#site-header .menu-item .badge { + color: white; + font-size: 10px; + padding: 2px 6px; + background-color: #0FD5C6; // somehow $topaz is too dark for me. +text-align: center; + text-decoration: none; + display: inline-block; + border-radius: 6px; + &:hover { + + } +} + +#site-header .menu-item button:hover, +#site-header .menu-item a:hover { + color: var(--color-topaz); +} + +#site-header .menu-item button .icon-external, +#site-header .menu-item a .icon-externa { + margin-left: auto; + opacity: .3; + flex-shrink: 0; +} + +#site-header .menu-item button .icon-caret, +#site-header .menu-item a .icon-caret { + opacity: .3; + height: 8px; +} + +#site-header .menu-item button .icon-slack, +#site-header .menu-item a .icon-slack, +#site-header .menu-item button .icon-github, +#site-header .menu-item a .icon-github { + height: 18px; +} + +#site-header .menu-item .menu-dropdown { + flex-direction: column; +} + +body #site-header .menu-item .menu-dropdown { + display: none; +} + +#site-header .menu-item.has-children.active .menu-dropdown { + display: flex; + z-index: 10; +} + +#site-header .menu-dropdown-item + .menu-dropdown-item { + border-top: 1px solid var(--color-mist); +} + +#site-header .menu-dropdown-item a { + display: flex; + gap: 10px; + align-items: center; + padding: 10px 20px; + font-weight: 500; + color: var(--color-eggplant); + text-decoration: none; + transition: + color 0.2s ease, + background 0.2s ease; +} + +#site-header .menu-dropdown-item a .icon-external { + margin-left: auto; + color: var(--color-fog); + flex-shrink: 0; + opacity: 1; +} + +#site-header .menu-dropdown-item a:hover { + background-color: var(--color-catskill-45); +} + +#site-header .menu-dropdown-item a:hover .icon-external { + color: var(--color-topaz); +} + +#site-header .menu-dropdown-item a img { + height: 24px; +} + +.md-header { + background-color: var(--color-catskill); + color: var(--color-eggplant); +} + +.md-header.md-header--shadow { + box-shadow: none; +} + +.md-header__inner.md-grid { + max-width: 100%; + padding: 1.5px 20px; +} + +[dir=ltr] .md-header__title { + margin: 0; +} + +.md-header__topic:first-child { + font-size: 16px; + font-weight: 500; + font-family: 'Rubik', Arial, Helvetica, sans-serif; +} + +.md-header__title.md-header__title--active .md-header__topic, +.md-header__title[data-md-state=active] .md-header__topic { + opacity: 1; + pointer-events: all; + transform: translateX(0); + transition: none; + z-index: 0; +} + +.md-header__topic a { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + transition: color .2s ease; +} + +.md-header__topic a:hover { + color: var(--color-topaz); +} + +div.md-header__source { + width: auto; +} + +div.md-source__repository { + max-width: 100%; +} + +.md-main { + padding: 0 12px; +} + +@media screen and (min-width: 60em) { + form.md-search__form { + background-color: #FBFBFF; + color: var(--color-storm); + } + + form.md-search__form:hover { + background-color: #fff; + } + + .md-search__input + .md-search__icon { + color: var(--color-plum); + } + + .md-search__input::placeholder { + color: var(--color-smoke); + } +} + +@media (min-width: 500px) { + #site-header { + font-size: 16px; + padding: 20px 40px; + } + #site-header .logo img, + #site-header .logo svg { + height: 48px; + } + + #site-header .menu-item button .icon-caret, + #site-header .menu-item a .icon-caret { + height: 10px; + } + + #site-header .menu-item button .icon-slack, + #site-header .menu-item a .icon-slack, + #site-header .menu-item button .icon-github, + #site-header .menu-item a .icon-github { + height: 24px; + } + + .md-header__inner.md-grid { + padding: 5px 40px; + } + + .md-main { + padding: 0 32px; + } +} + +@media (min-width: 1024px) { + #site-header #mobile-menu-toggle { + display: none; + } + + #site-header nav { + display: block; + } + + #site-header .menu { + display: flex; + justify-content: center; + gap: 30px; + } + + #site-header .menu-item { + align-items: center; + position: relative; + } + + #site-header .menu-item button, + #site-header .menu-item a { + min-height: 48px; + gap: 8px; + font-size: 16px; + } + + #site-header .menu-item .menu-dropdown { + position: absolute; + top: 100%; + right: -8px; + border: 1px solid var(--color-mist); + border-radius: 6px; + background: #fff; + box-shadow: 0px 30px 35px var(--color-smoke-75); + min-width: 200px; + } +} + + +@media (max-width: 1023px) { + #site-header { + flex-direction: column; + } + + body.mobile-tc-header-active #site-header { + z-index: 5; + } + + body.mobile-menu #site-header nav { + display: flex; + } + + #site-header nav { + position: absolute; + top: calc(100% - 5px); + width: calc(100% - 80px); + flex-direction: column; + border: 1px solid var(--color-mist); + border-radius: 6px; + background: #fff; + box-shadow: 0px 30px 35px var(--color-smoke-75); + min-width: 200px; + } + + #site-header .menu-item { + flex-direction: column; + } + #site-header .menu-item + .menu-item { + border-top: 1px solid var(--color-mist); + } + + #site-header .menu-item button, + #site-header .menu-item a { + padding: 10px 20px; + } + + #site-header .menu-item.has-children.active .menu-dropdown { + border-top: 1px solid var(--color-mist); + } + + #site-header .menu-dropdown-item a { + padding: 10px 20px 10px 30px; + } +} + +@media (max-width: 499px) { + #site-header nav { + width: calc(100% - 40px); + } +} diff --git a/docs/error_missing_container_runtime_environment.md b/docs/error_missing_container_runtime_environment.md new file mode 100644 index 00000000000..592eff7fd54 --- /dev/null +++ b/docs/error_missing_container_runtime_environment.md @@ -0,0 +1,16 @@ +# Fixing Issues with Discovering A Supported Container Runtime Environment + + +If you ended up on this page, +it seems that either Testcontainers was not able to find a supported container runtime in your environment, +or you found this page while searching for information to deal with errors regarding the environment discovery mechanism of Testcontainers. + +Testcontainers requires a supported container runtime environment to be present in order to manage and run containers. +Here is a list of supported container runtime environments: + +* [Docker Desktop](https://www.docker.com/products/docker-desktop/) +* [Docker Engine on Linux](https://docs.docker.com/engine/install/) +* [Testcontainers Cloud](https://www.testcontainers.cloud?utm_medium=direct&utm_source=testcontainers.com&utm_content=docs&utm_term=on-failure) + +For more extensive information on supported container runtime environments, as well as known limitations of alternative container runtime environments, +please refer to [this page](https://java.testcontainers.org/supported_docker_environment/) in our documentation. diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 00000000000..c63fa739817 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,19 @@ +# Examples + +Examples of different use cases provided by Testcontainers can be found below: + +- [Hazelcast](https://github.com/testcontainers/testcontainers-java/tree/main/examples/hazelcast) +- [Kafka Cluster with multiple brokers](https://github.com/testcontainers/testcontainers-java/tree/main/examples/kafka-cluster) +- [Neo4j](https://github.com/testcontainers/testcontainers-java/tree/main/examples/neo4j-container) +- [Redis](https://github.com/testcontainers/testcontainers-java/tree/main/examples/redis-backed-cache) +- [Selenium](https://github.com/testcontainers/testcontainers-java/tree/main/examples/selenium-container) +- [Selenium Module with Cucumber](https://github.com/testcontainers/testcontainers-java/tree/main/examples/cucumber) +- [Singleton Container Pattern](https://github.com/testcontainers/testcontainers-java/tree/main/examples/singleton-container) +- [Solr](https://github.com/testcontainers/testcontainers-java/tree/main/examples/solr-container) +- [Spring Boot](https://github.com/testcontainers/testcontainers-java/tree/main/examples/spring-boot) +- [Spring Boot with Kotlin](https://github.com/testcontainers/testcontainers-java/tree/main/examples/spring-boot-kotlin-redis) +- [TestNG](https://github.com/testcontainers/testcontainers-java/tree/main/examples/redis-backed-cache-testng) +- [ImmuDb](https://github.com/testcontainers/testcontainers-java/tree/main/examples/immudb) +- [Zookeeper](https://github.com/testcontainers/testcontainers-java/tree/main/examples/zookeeper) +- [NATS](https://github.com/testcontainers/testcontainers-java/tree/main/examples/nats) +- [SFTP](https://github.com/testcontainers/testcontainers-java/tree/main/examples/sftp) diff --git a/docs/examples/junit4/generic/build.gradle b/docs/examples/junit4/generic/build.gradle new file mode 100644 index 00000000000..60764dd7476 --- /dev/null +++ b/docs/examples/junit4/generic/build.gradle @@ -0,0 +1,12 @@ +description = "Examples for docs" + +dependencies { + testImplementation "junit:junit:4.13.2" + testImplementation project(":testcontainers") + testImplementation project(":testcontainers-selenium") + testImplementation project(":testcontainers-mysql") + + testRuntimeOnly 'com.mysql:mysql-connector-j:8.2.0' + testImplementation "org.seleniumhq.selenium:selenium-api:4.35.0" + testImplementation 'org.assertj:assertj-core:3.27.4' +} diff --git a/docs/examples/junit4/generic/src/test/java/generic/CmdModifierTest.java b/docs/examples/junit4/generic/src/test/java/generic/CmdModifierTest.java new file mode 100644 index 00000000000..52c7f621d07 --- /dev/null +++ b/docs/examples/junit4/generic/src/test/java/generic/CmdModifierTest.java @@ -0,0 +1,65 @@ +package generic; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.model.Info; +import org.junit.Rule; +import org.junit.Test; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CmdModifierTest { + + // hostname { + @Rule + public GenericContainer theCache = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) + .withCreateContainerCmdModifier(cmd -> cmd.withHostName("the-cache")); + + // } + + // spotless:off + // memory { + private long memoryInBytes = 32l * 1024l * 1024l; + + private long memorySwapInBytes = 64l * 1024l * 1024l; + + @Rule + public GenericContainer memoryLimitedRedis = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) + .withCreateContainerCmdModifier(cmd -> { + cmd.getHostConfig() + .withMemory(memoryInBytes) + .withMemorySwap(memorySwapInBytes); + }); + + // } + // spotless:on + + @Test + public void testHostnameModified() throws IOException, InterruptedException { + final Container.ExecResult execResult = theCache.execInContainer("hostname"); + assertThat(execResult.getStdout().trim()).isEqualTo("the-cache"); + } + + @Test + public void testMemoryLimitModified() throws IOException, InterruptedException { + final Container.ExecResult execResult = memoryLimitedRedis.execInContainer("cat", getMemoryLimitFilePath()); + assertThat(execResult.getStdout().trim()).isEqualTo(String.valueOf(memoryInBytes)); + } + + private String getMemoryLimitFilePath() { + DockerClient dockerClient = DockerClientFactory.instance().client(); + Info info = dockerClient.infoCmd().exec(); + Object cgroupVersion = info.getRawValues().get("CgroupVersion"); + boolean cgroup2 = Objects.equals("2", cgroupVersion); + if (cgroup2) { + return "/sys/fs/cgroup/memory.max"; + } + return "/sys/fs/cgroup/memory/memory.limit_in_bytes"; + } +} diff --git a/docs/examples/junit4/generic/src/test/java/generic/CommandsTest.java b/docs/examples/junit4/generic/src/test/java/generic/CommandsTest.java new file mode 100644 index 00000000000..5b4df0dbb66 --- /dev/null +++ b/docs/examples/junit4/generic/src/test/java/generic/CommandsTest.java @@ -0,0 +1,23 @@ +package generic; + +import org.junit.Rule; +import org.junit.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CommandsTest { + + @Rule + // startupCommand { + public GenericContainer redisWithCustomPort = new GenericContainer(DockerImageName.parse("redis:6-alpine")) + .withCommand("redis-server --port 7777") + // } + .withExposedPorts(7777); + + @Test + public void testStartupCommandOverrideApplied() { + assertThat(redisWithCustomPort.isRunning()).isTrue(); // good enough to check that the container started listening + } +} diff --git a/docs/examples/junit4/generic/src/test/java/generic/ContainerCreationTest.java b/docs/examples/junit4/generic/src/test/java/generic/ContainerCreationTest.java new file mode 100644 index 00000000000..e202580c247 --- /dev/null +++ b/docs/examples/junit4/generic/src/test/java/generic/ContainerCreationTest.java @@ -0,0 +1,45 @@ +package generic; + +import org.junit.ClassRule; +import org.junit.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ContainerCreationTest { + + // spotless:off + // simple { + public static final DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:6-alpine"); + + @ClassRule + public static GenericContainer redis = new GenericContainer<>(REDIS_IMAGE) + .withExposedPorts(6379); + + // } + // spotless:on + + public static final DockerImageName ALPINE_IMAGE = DockerImageName.parse("alpine:3.17"); + + // spotless:off + // withOptions { + // Set up a plain OS container and customize environment, + // command and exposed ports. This just listens on port 80 + // and always returns '42' + @ClassRule + public static GenericContainer alpine = new GenericContainer<>(ALPINE_IMAGE) + .withExposedPorts(80) + .withEnv("MAGIC_NUMBER", "42") + .withCommand("/bin/sh", "-c", + "while true; do echo \"$MAGIC_NUMBER\" | nc -l -p 80; done"); + + // } + // spotless:on + + @Test + public void testStartup() { + assertThat(redis.isRunning()).isTrue(); // good enough to check that the container started listening + assertThat(alpine.isRunning()).isTrue(); // good enough to check that the container started listening + } +} diff --git a/docs/examples/junit4/generic/src/test/java/generic/ContainerLabelTest.java b/docs/examples/junit4/generic/src/test/java/generic/ContainerLabelTest.java new file mode 100644 index 00000000000..73dac2bda9b --- /dev/null +++ b/docs/examples/junit4/generic/src/test/java/generic/ContainerLabelTest.java @@ -0,0 +1,23 @@ +package generic; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import java.util.HashMap; +import java.util.Map; + +public class ContainerLabelTest { + + // single_label { + public GenericContainer containerWithLabel = new GenericContainer(DockerImageName.parse("alpine:3.17")) + .withLabel("key", "value"); + // } + + // multiple_labels { + private Map mapOfLabels = new HashMap<>(); + // populate map, e.g. mapOfLabels.put("key1", "value1"); + + public GenericContainer containerWithMultipleLabels = new GenericContainer(DockerImageName.parse("alpine:3.17")) + .withLabels(mapOfLabels); + // } +} diff --git a/docs/examples/junit4/generic/src/test/java/generic/DependsOnTest.java b/docs/examples/junit4/generic/src/test/java/generic/DependsOnTest.java new file mode 100644 index 00000000000..c48d55899f0 --- /dev/null +++ b/docs/examples/junit4/generic/src/test/java/generic/DependsOnTest.java @@ -0,0 +1,27 @@ +package generic; + +import org.junit.Rule; +import org.junit.Test; +import org.testcontainers.containers.GenericContainer; + +import static org.assertj.core.api.Assertions.assertThat; + +public class DependsOnTest { + + @Rule + // dependsOn { + public GenericContainer redis = new GenericContainer<>("redis:6-alpine").withExposedPorts(6379); + + @Rule + public GenericContainer nginx = new GenericContainer<>("nginx:1.27.0-alpine3.19-slim") + .dependsOn(redis) + .withExposedPorts(80); + + // } + + @Test + public void testContainersAllStarted() { + assertThat(redis.isRunning()).isTrue(); + assertThat(nginx.isRunning()).isTrue(); + } +} diff --git a/docs/examples/junit4/generic/src/test/java/generic/ExampleImageNameSubstitutor.java b/docs/examples/junit4/generic/src/test/java/generic/ExampleImageNameSubstitutor.java new file mode 100644 index 00000000000..f6355312b3f --- /dev/null +++ b/docs/examples/junit4/generic/src/test/java/generic/ExampleImageNameSubstitutor.java @@ -0,0 +1,24 @@ +package generic; + +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.ImageNameSubstitutor; + +public class ExampleImageNameSubstitutor extends ImageNameSubstitutor { + + @Override + public DockerImageName apply(DockerImageName original) { + // convert the original name to something appropriate for + // our build environment + return DockerImageName.parse( + // your code goes here - silly example of capitalising + // the original name is shown + original.asCanonicalNameString().toUpperCase() + ); + } + + @Override + protected String getDescription() { + // used in logs + return "example image name substitutor"; + } +} diff --git a/docs/examples/junit4/generic/src/test/java/generic/ExecTest.java b/docs/examples/junit4/generic/src/test/java/generic/ExecTest.java new file mode 100644 index 00000000000..078e177242f --- /dev/null +++ b/docs/examples/junit4/generic/src/test/java/generic/ExecTest.java @@ -0,0 +1,33 @@ +package generic; + +import org.junit.Rule; +import org.junit.Test; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ExecTest { + + @Rule + public GenericContainer container = new GenericContainer<>(DockerImageName.parse("alpine:3.17")) + .withCommand("top"); + + @Test + public void testSimpleExec() throws IOException, InterruptedException { + // standaloneExec { + container.execInContainer("touch", "/somefile.txt"); + // } + + // execReadingStdout { + Container.ExecResult lsResult = container.execInContainer("ls", "-al", "/"); + String stdout = lsResult.getStdout(); + int exitCode = lsResult.getExitCode(); + assertThat(stdout).contains("somefile.txt"); + assertThat(exitCode).isZero(); + // } + } +} diff --git a/docs/examples/junit4/generic/src/test/java/generic/HostPortExposedTest.java b/docs/examples/junit4/generic/src/test/java/generic/HostPortExposedTest.java new file mode 100644 index 00000000000..392fb22c2a9 --- /dev/null +++ b/docs/examples/junit4/generic/src/test/java/generic/HostPortExposedTest.java @@ -0,0 +1,68 @@ +package generic; + +import com.sun.net.httpserver.HttpServer; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.testcontainers.Testcontainers; +import org.testcontainers.containers.BrowserWebDriverContainer; + +import java.io.OutputStream; +import java.net.InetSocketAddress; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HostPortExposedTest { + + private static HttpServer server; + + private static int localServerPort; + + @BeforeClass + public static void setUp() throws Exception { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/", + exchange -> { + byte[] content = "Hello World!".getBytes(); + exchange.sendResponseHeaders(200, content.length); + try (OutputStream responseBody = exchange.getResponseBody()) { + responseBody.write(content); + responseBody.flush(); + } + } + ); + + server.start(); + localServerPort = server.getAddress().getPort(); + + // exposePort { + Testcontainers.exposeHostPorts(localServerPort); + // } + } + + @AfterClass + public static void tearDown() throws Exception { + server.stop(0); + } + + @Rule + public BrowserWebDriverContainer browser = new BrowserWebDriverContainer<>() + .withCapabilities(new ChromeOptions()); + + @Test + public void testContainerRunningAgainstExposedHostPort() { + // useHostExposedPort { + final String rootUrl = String.format("http://host.testcontainers.internal:%d/", localServerPort); + + final RemoteWebDriver webDriver = new RemoteWebDriver(this.browser.getSeleniumAddress(), new ChromeOptions()); + webDriver.get(rootUrl); + // } + + final String pageSource = webDriver.getPageSource(); + assertThat(pageSource).contains("Hello World!"); + } +} diff --git a/docs/examples/junit4/generic/src/test/java/generic/ImageNameSubstitutionTest.java b/docs/examples/junit4/generic/src/test/java/generic/ImageNameSubstitutionTest.java new file mode 100644 index 00000000000..ce17637cb83 --- /dev/null +++ b/docs/examples/junit4/generic/src/test/java/generic/ImageNameSubstitutionTest.java @@ -0,0 +1,49 @@ +package generic; + +import generic.support.TestSpecificImageNameSubstitutor; +import org.junit.Test; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.utility.DockerImageName; + +public class ImageNameSubstitutionTest { + + @Test + public void simpleExample() { + try ( + // spotless:off + // directDockerHubReference { + // Referring directly to an image on Docker Hub (mysql:8.0.36) + final MySQLContainer mysql = new MySQLContainer<>( + DockerImageName.parse("mysql:8.0.36") + ) + // start the container and use it for testing + // } + // spotless:on + ) { + mysql.start(); + } + } + + /** + * Note that this test uses a fake image name, which will only work because + * {@link TestSpecificImageNameSubstitutor} steps in to override the substitution for this exact + * image name. + */ + @Test + public void substitutedExample() { + try ( + // spotless:off + // hardcodedMirror { + // Referring directly to an image on a private registry - image name will vary + final MySQLContainer mysql = new MySQLContainer<>( + DockerImageName.parse("registry.mycompany.com/mirror/mysql:8.0.36") + .asCompatibleSubstituteFor("mysql") + ) + // start the container and use it for testing + // } + // spotless:on + ) { + mysql.start(); + } + } +} diff --git a/docs/examples/junit4/generic/src/test/java/generic/MultiplePortsExposedTest.java b/docs/examples/junit4/generic/src/test/java/generic/MultiplePortsExposedTest.java new file mode 100644 index 00000000000..49e62cff217 --- /dev/null +++ b/docs/examples/junit4/generic/src/test/java/generic/MultiplePortsExposedTest.java @@ -0,0 +1,45 @@ +package generic; + +import org.junit.Rule; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.utility.DockerImageName; + +public class MultiplePortsExposedTest { + + private static final Logger log = LoggerFactory.getLogger(MultiplePortsExposedTest.class); + + @Rule + // rule { + public GenericContainer container = new GenericContainer<>( + DockerImageName.parse("testcontainers/helloworld:1.1.0") + ) + .withExposedPorts(8080, 8081) + .withLogConsumer(new Slf4jLogConsumer(log)); + + // } + + @Test + public void fetchPortsByNumber() { + Integer firstMappedPort = container.getMappedPort(8080); + Integer secondMappedPort = container.getMappedPort(8081); + } + + @Test + public void fetchFirstMappedPort() { + Integer firstMappedPort = container.getFirstMappedPort(); + } + + @Test + public void getHostOnly() { + String ipAddress = container.getHost(); + } + + @Test + public void getHostAndMappedPort() { + String address = container.getHost() + ":" + container.getMappedPort(8080); + } +} diff --git a/docs/examples/junit4/generic/src/test/java/generic/WaitStrategiesTest.java b/docs/examples/junit4/generic/src/test/java/generic/WaitStrategiesTest.java new file mode 100644 index 00000000000..c66fc9443d0 --- /dev/null +++ b/docs/examples/junit4/generic/src/test/java/generic/WaitStrategiesTest.java @@ -0,0 +1,78 @@ +package generic; + +import org.junit.Rule; +import org.junit.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.utility.DockerImageName; + +import static org.assertj.core.api.Assertions.assertThat; + +public class WaitStrategiesTest { + + @Rule + // waitForNetworkListening { + public GenericContainer nginx = new GenericContainer(DockerImageName.parse("nginx:1.27.0-alpine3.19-slim")) // + .withExposedPorts(80); + + // } + + @Rule + // waitForSimpleHttp { + public GenericContainer nginxWithHttpWait = new GenericContainer( + DockerImageName.parse("nginx:1.27.0-alpine3.19-slim") + ) + .withExposedPorts(80) + .waitingFor(Wait.forHttp("/")); + + // } + + @Rule + // logMessageWait { + public GenericContainer containerWithLogWait = new GenericContainer(DockerImageName.parse("redis:6-alpine")) + .withExposedPorts(6379) + .waitingFor(Wait.forLogMessage(".*Ready to accept connections.*\\n", 1)); + + // } + + private static final HttpWaitStrategy MULTI_CODE_HTTP_WAIT = + // spotless:off + // waitForHttpWithMultipleStatusCodes { + Wait.forHttp("/") + .forStatusCode(200) + .forStatusCode(301); + // } + // spotless:on + + private static final HttpWaitStrategy PREDICATE_HTTP_WAIT = + // spotless:off + // waitForHttpWithStatusCodePredicate { + Wait.forHttp("/all") + .forStatusCodeMatching(it -> it >= 200 && it < 300 || it == 401); + // } + // spotless:on + + private static final HttpWaitStrategy TLS_HTTP_WAIT = + // spotless:off + // waitForHttpWithTls { + Wait.forHttp("/all") + .usingTls(); + // } + // spotless:on + + private static final WaitStrategy HEALTHCHECK_WAIT = + // spotless:off + // healthcheckWait { + Wait.forHealthcheck(); + // } + // spotless:on + + @Test + public void testContainersAllStarted() { + assertThat(nginx.isRunning()).isTrue(); + assertThat(nginxWithHttpWait.isRunning()).isTrue(); + assertThat(containerWithLogWait.isRunning()).isTrue(); + } +} diff --git a/docs/examples/junit4/generic/src/test/java/generic/support/TestSpecificImageNameSubstitutor.java b/docs/examples/junit4/generic/src/test/java/generic/support/TestSpecificImageNameSubstitutor.java new file mode 100644 index 00000000000..0850c7c7a50 --- /dev/null +++ b/docs/examples/junit4/generic/src/test/java/generic/support/TestSpecificImageNameSubstitutor.java @@ -0,0 +1,26 @@ +package generic.support; + +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.ImageNameSubstitutor; + +/** + * An {@link ImageNameSubstitutor} which makes it possible to use fake image names in + * {@link generic.ImageNameSubstitutionTest}. This implementation simply reverses a fake image name when presented, and + * is hardcoded to act upon the specific fake name in that test. + */ +public class TestSpecificImageNameSubstitutor extends ImageNameSubstitutor { + + @Override + public DockerImageName apply(final DockerImageName original) { + if (original.equals(DockerImageName.parse("registry.mycompany.com/mirror/mysql:8.0.36"))) { + return DockerImageName.parse("mysql:8.0.36"); + } else { + return original; + } + } + + @Override + protected String getDescription() { + return TestSpecificImageNameSubstitutor.class.getSimpleName(); + } +} diff --git a/docs/examples/junit4/generic/src/test/java/org/testcontainers/containers/startupcheck/StartupCheckStrategyTest.java b/docs/examples/junit4/generic/src/test/java/org/testcontainers/containers/startupcheck/StartupCheckStrategyTest.java new file mode 100644 index 00000000000..82e3d83ab24 --- /dev/null +++ b/docs/examples/junit4/generic/src/test/java/org/testcontainers/containers/startupcheck/StartupCheckStrategyTest.java @@ -0,0 +1,110 @@ +package org.testcontainers.containers.startupcheck; + +import lombok.SneakyThrows; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Suite; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.containers.output.WaitingConsumer; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(Suite.class) +@Suite.SuiteClasses( + { + StartupCheckStrategyTest.OneShotStrategyTest.class, + StartupCheckStrategyTest.IndefiniteOneShotStrategyTest.class, + StartupCheckStrategyTest.MinimumDurationStrategyTest.class, + } +) +public class StartupCheckStrategyTest { + + private static final String HELLO_TESTCONTAINERS = "Hello Testcontainers!"; + + private static void waitForHello(GenericContainer container) throws TimeoutException { + WaitingConsumer consumer = new WaitingConsumer(); + container.followOutput(consumer, OutputFrame.OutputType.STDOUT); + + consumer.waitUntil(frame -> frame.getUtf8String().contains(HELLO_TESTCONTAINERS), 30, TimeUnit.SECONDS); + } + + public static class OneShotStrategyTest { + + @Rule + // spotless:off + // withOneShotStrategy { + public GenericContainer bboxWithOneShot = new GenericContainer<>(DockerImageName.parse("busybox:1.31.1")) + .withCommand(String.format("echo %s", HELLO_TESTCONTAINERS)) + .withStartupCheckStrategy( + new OneShotStartupCheckStrategy().withTimeout(Duration.ofSeconds(3)) + ); + + // } + // spotless:on + + @SneakyThrows + @Test + public void testCommandIsExecuted() { + waitForHello(bboxWithOneShot); + + assertThat(bboxWithOneShot.isRunning()).isFalse(); + } + } + + public static class IndefiniteOneShotStrategyTest { + + @Rule + // spotless:off + // withIndefiniteOneShotStrategy { + public GenericContainer bboxWithIndefiniteOneShot = new GenericContainer<>( + DockerImageName.parse("busybox:1.31.1") + ) + .withCommand("sh", "-c", String.format("sleep 5 && echo \"%s\"", HELLO_TESTCONTAINERS)) + .withStartupCheckStrategy( + new IndefiniteWaitOneShotStartupCheckStrategy() + ); + + // } + // spotless:on + + @SneakyThrows + @Test + public void testCommandIsExecuted() { + waitForHello(bboxWithIndefiniteOneShot); + + assertThat(bboxWithIndefiniteOneShot.isRunning()).isFalse(); + } + } + + public static class MinimumDurationStrategyTest { + + @Rule + // spotless:off + // withMinimumDurationStrategy { + public GenericContainer bboxWithMinimumDuration = new GenericContainer<>( + DockerImageName.parse("busybox:1.31.1") + ) + .withCommand("sh", "-c", String.format("sleep 5 && echo \"%s\"", HELLO_TESTCONTAINERS)) + .withStartupCheckStrategy( + new MinimumDurationRunningStartupCheckStrategy(Duration.ofSeconds(1)) + ); + + // } + // spotless:on + + @SneakyThrows + @Test + public void testCommandIsExecuted() { + assertThat(bboxWithMinimumDuration.isRunning()).isTrue(); + + waitForHello(bboxWithMinimumDuration); + } + } +} diff --git a/docs/examples/junit4/generic/src/test/resources/logback-test.xml b/docs/examples/junit4/generic/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..1378a823a63 --- /dev/null +++ b/docs/examples/junit4/generic/src/test/resources/logback-test.xml @@ -0,0 +1,14 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n + + + + + + + + + + diff --git a/docs/examples/junit4/generic/src/test/resources/testcontainers.properties b/docs/examples/junit4/generic/src/test/resources/testcontainers.properties new file mode 100644 index 00000000000..19bf8288d33 --- /dev/null +++ b/docs/examples/junit4/generic/src/test/resources/testcontainers.properties @@ -0,0 +1 @@ +image.substitutor=generic.support.TestSpecificImageNameSubstitutor diff --git a/docs/examples/junit4/redis/build.gradle b/docs/examples/junit4/redis/build.gradle new file mode 100644 index 00000000000..8af186d16bb --- /dev/null +++ b/docs/examples/junit4/redis/build.gradle @@ -0,0 +1,9 @@ +description = "Examples for docs" + +dependencies { + api "io.lettuce:lettuce-core:6.8.0.RELEASE" + + testImplementation "junit:junit:4.13.2" + testImplementation project(":testcontainers") + testImplementation 'org.assertj:assertj-core:3.27.4' +} diff --git a/docs/examples/junit4/redis/src/main/java/quickstart/RedisBackedCache.java b/docs/examples/junit4/redis/src/main/java/quickstart/RedisBackedCache.java new file mode 100644 index 00000000000..1123858ca17 --- /dev/null +++ b/docs/examples/junit4/redis/src/main/java/quickstart/RedisBackedCache.java @@ -0,0 +1,22 @@ +package quickstart; + +import io.lettuce.core.RedisClient; +import io.lettuce.core.api.StatefulRedisConnection; + +public class RedisBackedCache { + + private final StatefulRedisConnection connection; + + public RedisBackedCache(String hostname, Integer port) { + RedisClient client = RedisClient.create(String.format("redis://%s:%d/0", hostname, port)); + connection = client.connect(); + } + + public String get(String key) { + return connection.sync().get(key); + } + + public void put(String key, String value) { + connection.sync().set(key, value); + } +} diff --git a/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java b/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java new file mode 100644 index 00000000000..e4c4424315b --- /dev/null +++ b/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java @@ -0,0 +1,38 @@ +package quickstart; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import static org.assertj.core.api.Assertions.assertThat; + +public class RedisBackedCacheIntTest { + + private RedisBackedCache underTest; + + // rule { + @Rule + public GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:6-alpine")) + .withExposedPorts(6379); + + // } + + @Before + public void setUp() { + String address = redis.getHost(); + Integer port = redis.getFirstMappedPort(); + + // Now we have an address and port for Redis, no matter where it is running + underTest = new RedisBackedCache(address, port); + } + + @Test + public void testSimplePutAndGet() { + underTest.put("test", "example"); + + String retrieved = underTest.get("test"); + assertThat(retrieved).isEqualTo("example"); + } +} diff --git a/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java b/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java new file mode 100644 index 00000000000..e3f52403c48 --- /dev/null +++ b/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java @@ -0,0 +1,27 @@ +package quickstart; + +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@Ignore("This test class is deliberately invalid, as it relies on a non-existent local Redis") +public class RedisBackedCacheIntTestStep0 { + + private RedisBackedCache underTest; + + @Before + public void setUp() { + // Assume that we have Redis running locally? + underTest = new RedisBackedCache("localhost", 6379); + } + + @Test + public void testSimplePutAndGet() { + underTest.put("test", "example"); + + String retrieved = underTest.get("test"); + assertThat(retrieved).isEqualTo("example"); + } +} diff --git a/docs/examples/junit4/redis/src/test/resources/logback-test.xml b/docs/examples/junit4/redis/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..62bc2c43d3a --- /dev/null +++ b/docs/examples/junit4/redis/src/test/resources/logback-test.xml @@ -0,0 +1,13 @@ + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + diff --git a/docs/examples/junit5/redis/build.gradle b/docs/examples/junit5/redis/build.gradle new file mode 100644 index 00000000000..277ec0b8ae5 --- /dev/null +++ b/docs/examples/junit5/redis/build.gradle @@ -0,0 +1,15 @@ +description = "Examples for docs" + +dependencies { + api "io.lettuce:lettuce-core:6.8.0.RELEASE" + + testRuntimeOnly 'org.junit.jupiter:junit-jupiter:5.13.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.3' + testImplementation project(":testcontainers") + testImplementation project(":testcontainers-junit-jupiter") + testImplementation 'org.assertj:assertj-core:3.27.4' +} + +test { + useJUnitPlatform() +} diff --git a/docs/examples/junit5/redis/src/main/java/quickstart/RedisBackedCache.java b/docs/examples/junit5/redis/src/main/java/quickstart/RedisBackedCache.java new file mode 100644 index 00000000000..1123858ca17 --- /dev/null +++ b/docs/examples/junit5/redis/src/main/java/quickstart/RedisBackedCache.java @@ -0,0 +1,22 @@ +package quickstart; + +import io.lettuce.core.RedisClient; +import io.lettuce.core.api.StatefulRedisConnection; + +public class RedisBackedCache { + + private final StatefulRedisConnection connection; + + public RedisBackedCache(String hostname, Integer port) { + RedisClient client = RedisClient.create(String.format("redis://%s:%d/0", hostname, port)); + connection = client.connect(); + } + + public String get(String key) { + return connection.sync().get(key); + } + + public void put(String key, String value) { + connection.sync().set(key, value); + } +} diff --git a/docs/examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java b/docs/examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java new file mode 100644 index 00000000000..b61763471f2 --- /dev/null +++ b/docs/examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java @@ -0,0 +1,42 @@ +package quickstart; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import static org.assertj.core.api.Assertions.assertThat; + +// class { +@Testcontainers +public class RedisBackedCacheIntTest { + + private RedisBackedCache underTest; + + // container { + @Container + public GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:6-alpine")) + .withExposedPorts(6379); + + // } + + @BeforeEach + public void setUp() { + String address = redis.getHost(); + Integer port = redis.getFirstMappedPort(); + + // Now we have an address and port for Redis, no matter where it is running + underTest = new RedisBackedCache(address, port); + } + + @Test + public void testSimplePutAndGet() { + underTest.put("test", "example"); + + String retrieved = underTest.get("test"); + assertThat(retrieved).isEqualTo("example"); + } +} +// } diff --git a/docs/examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java b/docs/examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java new file mode 100644 index 00000000000..e1f3c3efbe8 --- /dev/null +++ b/docs/examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java @@ -0,0 +1,27 @@ +package quickstart; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@Disabled("This test class is deliberately invalid, as it relies on a non-existent local Redis") +public class RedisBackedCacheIntTestStep0 { + + private RedisBackedCache underTest; + + @BeforeEach + public void setUp() { + // Assume that we have Redis running locally? + underTest = new RedisBackedCache("localhost", 6379); + } + + @Test + public void testSimplePutAndGet() { + underTest.put("test", "example"); + + String retrieved = underTest.get("test"); + assertThat(retrieved).isEqualTo("example"); + } +} diff --git a/shade-test/service-lookup-dropwizard/src/test/resources/logback-test.xml b/docs/examples/junit5/redis/src/test/resources/logback-test.xml similarity index 77% rename from shade-test/service-lookup-dropwizard/src/test/resources/logback-test.xml rename to docs/examples/junit5/redis/src/test/resources/logback-test.xml index d12e392071a..49da0be9709 100644 --- a/shade-test/service-lookup-dropwizard/src/test/resources/logback-test.xml +++ b/docs/examples/junit5/redis/src/test/resources/logback-test.xml @@ -8,23 +8,20 @@ - + + + - - - - - PROFILER DENY - \ No newline at end of file + diff --git a/docs/examples/spock/redis/build.gradle b/docs/examples/spock/redis/build.gradle new file mode 100644 index 00000000000..30a5509929e --- /dev/null +++ b/docs/examples/spock/redis/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'java' + id 'groovy' +} + +dependencies { + api "io.lettuce:lettuce-core:6.8.0.RELEASE" + testImplementation 'org.spockframework:spock-core:2.3-groovy-4.0' + testImplementation project(":testcontainers-spock") + testImplementation 'ch.qos.logback:logback-classic:1.3.15' +} + +test { + useJUnitPlatform() +} diff --git a/docs/examples/spock/redis/src/main/java/quickstart/RedisBackedCache.java b/docs/examples/spock/redis/src/main/java/quickstart/RedisBackedCache.java new file mode 100644 index 00000000000..1123858ca17 --- /dev/null +++ b/docs/examples/spock/redis/src/main/java/quickstart/RedisBackedCache.java @@ -0,0 +1,22 @@ +package quickstart; + +import io.lettuce.core.RedisClient; +import io.lettuce.core.api.StatefulRedisConnection; + +public class RedisBackedCache { + + private final StatefulRedisConnection connection; + + public RedisBackedCache(String hostname, Integer port) { + RedisClient client = RedisClient.create(String.format("redis://%s:%d/0", hostname, port)); + connection = client.connect(); + } + + public String get(String key) { + return connection.sync().get(key); + } + + public void put(String key, String value) { + connection.sync().set(key, value); + } +} diff --git a/docs/examples/spock/redis/src/test/groovy/quickstart/RedisBackedCacheIntTest.groovy b/docs/examples/spock/redis/src/test/groovy/quickstart/RedisBackedCacheIntTest.groovy new file mode 100644 index 00000000000..407e49cc638 --- /dev/null +++ b/docs/examples/spock/redis/src/test/groovy/quickstart/RedisBackedCacheIntTest.groovy @@ -0,0 +1,36 @@ +package quickstart + +import org.testcontainers.containers.GenericContainer +import spock.lang.Specification + +// complete { +@org.testcontainers.spock.Testcontainers +class RedisBackedCacheIntTest extends Specification { + + private RedisBackedCache underTest + + // init { + GenericContainer redis = new GenericContainer<>("redis:6-alpine") + .withExposedPorts(6379) + // } + + void setup() { + String address = redis.host + Integer port = redis.firstMappedPort + + // Now we have an address and port for Redis, no matter where it is running + underTest = new RedisBackedCache(address, port) + } + + void testSimplePutAndGet() { + setup: + underTest.put("test", "example") + + when: + String retrieved = underTest.get("test") + + then: + retrieved == "example" + } +} +// } diff --git a/docs/examples/spock/redis/src/test/groovy/quickstart/RedisBackedCacheIntTestStep0.groovy b/docs/examples/spock/redis/src/test/groovy/quickstart/RedisBackedCacheIntTestStep0.groovy new file mode 100644 index 00000000000..d28942420da --- /dev/null +++ b/docs/examples/spock/redis/src/test/groovy/quickstart/RedisBackedCacheIntTestStep0.groovy @@ -0,0 +1,25 @@ +package quickstart + +import spock.lang.Ignore +import spock.lang.Specification + +@Ignore("This test class is deliberately invalid, as it relies on a non-existent local Redis") +class RedisBackedCacheIntTestStep0 extends Specification { + private RedisBackedCache underTest + + void setup() { + // Assume that we have Redis running locally? + underTest = new RedisBackedCache("localhost", 6379) + } + + void testSimplePutAndGet() { + setup: + underTest.put("test", "example") + + when: + String retrieved = underTest.get("test") + + then: + retrieved == "example" + } +} diff --git a/docs/examples/spock/redis/src/test/resources/logback-test.xml b/docs/examples/spock/redis/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..535e406fc13 --- /dev/null +++ b/docs/examples/spock/redis/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 00000000000..311a0acaa37 Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/features/advanced_options.md b/docs/features/advanced_options.md new file mode 100644 index 00000000000..e979da9ecdf --- /dev/null +++ b/docs/features/advanced_options.md @@ -0,0 +1,93 @@ +# Advanced options + +## Container labels + +To add a custom label to the container, use `withLabel`: + + +[Adding a single label](../examples/junit4/generic/src/test/java/generic/ContainerLabelTest.java) inside_block:single_label + + +Additionally, multiple labels may be applied together from a map: + + +[Adding multiple labels](../examples/junit4/generic/src/test/java/generic/ContainerLabelTest.java) inside_block:multiple_labels + + +## Image Pull Policy + +By default, the container image is retrieved from the local Docker images cache. +This works well when running against a specific version, but for images with a static tag (i.e. 'latest') this may lead to a newer version not being pulled. + +It is possible to specify an Image Pull Policy to determine at runtime whether an image should be pulled or not: + + +[Setting image pull policy](../../core/src/test/java/org/testcontainers/images/ImagePullPolicyTest.java) inside_block:built_in_image_pull_policy + + +... or providing a function: + + +[Custom image pull policy](../../core/src/test/java/org/testcontainers/images/ImagePullPolicyTest.java) inside_block:custom_image_pull_policy + + +You can also configure Testcontainers to use your custom implementation by using `pull.policy` + +=== "`src/test/resources/testcontainers.properties`" + ```text + pull.policy=com.mycompany.testcontainers.ExampleImagePullPolicy + ``` + +You can also use the provided implementation to always pull images + +=== "`src/test/resources/testcontainers.properties`" + ```text + pull.policy=org.testcontainers.images.AlwaysPullPolicy + ``` + + +Please see [the documentation on configuration mechanisms](./configuration.md) for more information. + +## Customizing the container + +### Using docker-java + +It is possible to use the [`docker-java`](https://github.com/docker-java/docker-java) API directly to customize containers before creation. This is useful if there is a need to use advanced Docker features that are not exposed by the Testcontainers API. Any customizations you make using `withCreateContainerCmdModifier` will be applied _on top_ of the container definition that Testcontainers creates, but before it is created. + +For example, this can be used to change the container hostname: + + +[Using modifier to change hostname](../examples/junit4/generic/src/test/java/generic/CmdModifierTest.java) inside_block:hostname + + +... or modify container memory (see [this](https://fabiokung.com/2014/03/13/memory-inside-linux-containers/) if it does not appear to work): + + +[Using modifier to change memory limits](../examples/junit4/generic/src/test/java/generic/CmdModifierTest.java) inside_block:memory + + +!!! note + It is recommended to use this sparingly, and follow changes to the `docker-java` API if you choose to use this. + It is typically quite stable, though. + +For what is possible, consult the [`docker-java CreateContainerCmd` source code](https://github.com/docker-java/docker-java/blob/3.2.1/docker-java-api/src/main/java/com/github/dockerjava/api/command/CreateContainerCmd.java). + +### Using CreateContainerCmdModifier + +Testcontainers provides a `CreateContainerCmdModifier` to customize [`docker-java CreateContainerCmd`](https://github.com/docker-java/docker-java/blob/3.2.1/docker-java-api/src/main/java/com/github/dockerjava/api/command/CreateContainerCmd.java) +via Service Provider Interface (SPI) mechanism. + + +[CreateContainerCmd example implementation](../../core/src/test/java/org/testcontainers/custom/TestCreateContainerCmdModifier.java) + + +The previous implementation should be registered in `META-INF/services/org.testcontainers.core.CreateContainerCmdModifier` file. + +!!! warning + `CreateContainerCmdModifier` implementation will apply to all containers created by Testcontainers. + +## Parallel Container Startup + +Usually, containers are started sequentially when more than one container is used. +Using `Startables.deepStart(container1, container2, ...).join()` will start all containers in parallel. +This can be advantageous to reduce the impact of the container startup overhead. diff --git a/docs/features/commands.md b/docs/features/commands.md new file mode 100644 index 00000000000..40bcce5d1ab --- /dev/null +++ b/docs/features/commands.md @@ -0,0 +1,31 @@ +# Executing commands + +## Container startup command + +By default the container will execute whatever command is specified in the image's Dockerfile. To override this, and specify a different command, use `withCommand`. For example: + + +[Specifying a startup command](../examples/junit4/generic/src/test/java/generic/CommandsTest.java) inside_block:startupCommand + + +## Executing a command + +Your test can execute a command inside a running container, similar to a `docker exec` call: + + +[Executing a command inside a running container](../examples/junit4/generic/src/test/java/generic/ExecTest.java) inside_block:standaloneExec + + +This can be useful for software that has a command line administration tool. You can also get the output (stdout/stderr) and exit code from the command - for example: + + +[Executing a command inside a running container and reading the result](../examples/junit4/generic/src/test/java/generic/ExecTest.java) inside_block:execReadingStdout + + +## Environment variables + +To add environment variables to the container, use `withEnv`: +```java +new GenericContainer(...) + .withEnv("API_TOKEN", "foo") +``` diff --git a/docs/features/configuration.md b/docs/features/configuration.md new file mode 100644 index 00000000000..389936cb89f --- /dev/null +++ b/docs/features/configuration.md @@ -0,0 +1,134 @@ +# Custom configuration + +You can override some default properties if your environment requires that. + +## Configuration locations +The configuration will be loaded from multiple locations. Properties are considered in the following order: + +1. Environment variables +2. `.testcontainers.properties` in user's home folder. Example locations: +**Linux:** `/home/myuser/.testcontainers.properties` +**Windows:** `C:/Users/myuser/.testcontainers.properties` +**macOS:** `/Users/myuser/.testcontainers.properties` +3. `testcontainers.properties` on the classpath. + +Note that when using environment variables, configuration property names should be set in upper +case with underscore separators, preceded by `TESTCONTAINERS_` - e.g. `checks.disable` becomes +`TESTCONTAINERS_CHECKS_DISABLE`. + +The classpath `testcontainers.properties` file may exist within the local codebase (e.g. within the `src/test/resources` directory) or within library dependencies that you may have. +Any such configuration files will have their contents merged. +If any keys conflict, the value will be taken on the basis of the first value found in: + +* 'local' classpath (i.e. where the URL of the file on the classpath begins with `file:`), then +* other classpath locations (i.e. JAR files) - considered in _alphabetical order of path_ to provide deterministic ordering. + +## Disabling the startup checks +> **checks.disable = [true|false]** + +Before running any containers Testcontainers will perform a set of startup checks to ensure that your environment is configured correctly. Usually they look like this: +``` + ℹ︎ Checking the system... + ✔ Docker version should be at least 1.6.0 + ✔ File should be mountable + ✔ A port exposed by a docker container should be accessible +``` +It takes a couple of seconds, but if you want to speed up your tests, you can disable the checks once you have everything configured. Add `checks.disable=true` to your `$HOME/.testcontainers.properties` to completely disable them. + +## Customizing images + +!!! note + This approach is discouraged and deprecated, but is documented for completeness. + Overriding individual image names via configuration may be removed in 2021. + See [Image Name Substitution](./image_name_substitution.md) for other strategies for substituting image names to pull from other registries. + + +Testcontainers uses public Docker images to perform different actions like startup checks, VNC recording and others. +Some companies disallow the usage of Docker Hub, but you can override `*.image` properties with your own images from your private registry to workaround that. + +> **ryuk.container.image = testcontainers/ryuk:0.3.3** +> Performs fail-safe cleanup of containers, and always required (unless [Ryuk is disabled](#disabling-ryuk)) + +> **tinyimage.container.image = alpine:3.17** +> Used to check whether images can be pulled at startup, and always required (unless [startup checks are disabled](#disabling-the-startup-checks)) + +> **sshd.container.image = testcontainers/sshd:1.4.0** +> Required if [exposing host ports to containers](./networking.md#exposing-host-ports-to-the-container) + +> **vncrecorder.container.image = testcontainers/vnc-recorder:1.3.0** +> Used by VNC recorder in Testcontainers' Selenium integration + +> **socat.container.image = alpine/socat** +> **compose.container.image = docker/compose:1.8.0** +> Required if using [Docker Compose](../modules/docker_compose.md) + +> **kafka.container.image = confluentinc/cp-kafka** +> Used by KafkaContainer + +> **localstack.container.image = localstack/localstack** +> Used by LocalStack + +> **pulsar.container.image = apachepulsar/pulsar:2.2.0** +> Used by Apache Pulsar + +## Customizing Ryuk resource reaper + +> **ryuk.container.image = testcontainers/ryuk:0.3.3** +> The resource reaper is responsible for container removal and automatic cleanup of dead containers at JVM shutdown + +> **ryuk.container.privileged = true** +> In some environments ryuk must be started in privileged mode to work properly (--privileged flag) + +### Disabling Ryuk +Ryuk must be started as a privileged container. +If your environment already implements automatic cleanup of containers after the execution, +but does not allow starting privileged containers, you can turn off the Ryuk container by setting +`TESTCONTAINERS_RYUK_DISABLED` **environment variable** to `true`. + +!!!tip + Note that Testcontainers will continue doing the cleanup at JVM's shutdown, unless you `kill -9` your JVM process. + +## Customizing image pull behaviour + +> **pull.timeout = 120** +> By default Testcontainers will timeout if pull takes more than this duration (in seconds) + +> **pull.pause.timeout = 30** +> By default Testcontainers will abort the pull of an image if the pull appears stalled (no data transferred) for longer than this duration (in seconds). + +## Customizing client ping behaviour + +> **client.ping.timeout = 10** +> Specifies for how long Testcontainers will try to connect to the Docker client to obtain valid info about the client before giving up and trying next strategy, if applicable (in seconds). + +## Customizing Docker host detection + +Testcontainers will attempt to detect the Docker environment and configure everything to work automatically. + +However, sometimes customization is required. Testcontainers will respect the following **environment variables**: + +> **DOCKER_HOST** = unix:///var/run/docker.sock +> See [Docker environment variables](https://docs.docker.com/engine/reference/commandline/cli/#environment-variables) +> +> **TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE** +> Path to Docker's socket. Used by Ryuk, Docker Compose, and a few other containers that need to perform Docker actions. +> Example: `/var/run/docker-alt.sock` +> +> **TESTCONTAINERS_HOST_OVERRIDE** +> Docker's host on which ports are exposed. +> Example: `docker.svc.local` + +For advanced users, the Docker host connection can be configured **via configuration** in `~/.testcontainers.properties`. +Note that these settings require use of the `EnvironmentAndSystemPropertyClientProviderStrategy`. The example below +illustrates usage: + +```properties +docker.client.strategy=org.testcontainers.dockerclient.EnvironmentAndSystemPropertyClientProviderStrategy +docker.host=tcp\://my.docker.host\:1234 # Equivalent to the DOCKER_HOST environment variable. Colons should be escaped. +docker.tls.verify=1 # Equivalent to the DOCKER_TLS_VERIFY environment variable +docker.cert.path=/some/path # Equivalent to the DOCKER_CERT_PATH environment variable +``` +In addition, you can deactivate this behaviour by specifying: +```properties +dockerconfig.source=autoIgnoringUserProperties # 'auto' by default +``` diff --git a/docs/features/container_logs.md b/docs/features/container_logs.md new file mode 100644 index 00000000000..a063e5329e4 --- /dev/null +++ b/docs/features/container_logs.md @@ -0,0 +1,99 @@ +# Accessing container logs + +It is possible to capture container output using: + + * the `getLogs()` method, which simply returns a `String` snapshot of a container's entire log output + * the `followOutput()` method. This method accepts a Consumer and (optionally) +a varargs list stating which of STDOUT, STDERR, or both, should be followed. If not specified, both will be followed. + +At present, container output will always begin from the time of container creation. + +## Reading all logs (from startup time to present) + +`getLogs()` is the simplest mechanism for accessing container logs, and can be used as follows: + + +[Accessing all output (stdout and stderr)](../../core/src/test/java/org/testcontainers/containers/output/ContainerLogsTest.java) inside_block:docsGetAllLogs + + + +[Accessing just stdout](../../core/src/test/java/org/testcontainers/containers/output/ContainerLogsTest.java) inside_block:docsGetStdOut + + + +[Accessing just stderr](../../core/src/test/java/org/testcontainers/containers/output/ContainerLogsTest.java) inside_block:docsGetStdErr + + +## Streaming logs + +Testcontainers includes some out-of-the-box Consumer implementations that can be used with the streaming `followOutput()` model; examples follow. + +### Streaming container output to an SLF4J logger + +Given an existing SLF4J logger instance named LOGGER: +```java +Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(LOGGER); +container.followOutput(logConsumer); +``` + +By default both standard out and standard error will both be emitted at INFO level. +Standard error may be emitted at ERROR level, if desired: + +```java +Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(LOGGER).withSeparateOutputStreams(); +``` + +The [Mapped Diagnostic Context (MDC)](http://logback.qos.ch/manual/mdc.html) for emitted messages may be configured using the `withMdc(...)` option: + +```java +Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(LOGGER).withMdc("key", "value"); +``` + +or using an existing map of key-value pairs: + +```java +Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(LOGGER).withMdc(map); +``` + +### Capturing container output as a String + +To stream logs live or customize the decoding, `ToStringConsumer` may be used: + +```java +ToStringConsumer toStringConsumer = new ToStringConsumer(); +container.followOutput(toStringConsumer, OutputType.STDOUT); + +String utf8String = toStringConsumer.toUtf8String(); + +// Or if the container output is not UTF-8 +String otherString = toStringConsumer.toString(CharSet.forName("ISO-8859-1")); +``` + +### Waiting for container output to contain expected content + +`WaitingConsumer` will block until a frame of container output (usually a line) matches a provided predicate. + +A timeout may be specified, as shown in this example. +```java +WaitingConsumer consumer = new WaitingConsumer(); + +container.followOutput(consumer, STDOUT); + +consumer.waitUntil(frame -> + frame.getUtf8String().contains("STARTED"), 30, TimeUnit.SECONDS); +``` + +Additionally, as the Java 8 Consumer functional interface is used, Consumers may be composed together. This is +useful, for example, to capture all the container output but only when a matching string has been found. e.g.: +```java +WaitingConsumer waitingConsumer = new WaitingConsumer(); +ToStringConsumer toStringConsumer = new ToStringConsumer(); + +Consumer composedConsumer = toStringConsumer.andThen(waitingConsumer); +container.followOutput(composedConsumer); + +waitingConsumer.waitUntil(frame -> + frame.getUtf8String().contains("STARTED"), 30, TimeUnit.SECONDS); + +String utf8String = toStringConsumer.toUtf8String(); +``` diff --git a/docs/features/creating_container.md b/docs/features/creating_container.md new file mode 100644 index 00000000000..baaea5fb36d --- /dev/null +++ b/docs/features/creating_container.md @@ -0,0 +1,41 @@ +# Creating a container + +## Creating a generic container based on an image + +Testcontainers' generic container support offers the most flexibility, and makes it easy to use virtually any container +images as temporary test dependencies. For example, if you might use it to test interactions with: + +* NoSQL databases or other data stores (e.g. redis, elasticsearch, mongo) +* Web servers/proxies (e.g. nginx, apache) +* Log services (e.g. logstash, kibana) +* Other services developed by your team/organization which are already dockerized + +With a generic container, you set the container image using a parameter to the rule constructor, e.g.: +```java +new GenericContainer(DockerImageName.parse("jboss/wildfly:9.0.1.Final")) +``` + +### Specifying an image + +Many Container classes in Testcontainers have historically supported: + +* a no-args constructor - for example `new GenericContainer()` and `new ElasticsearchContainer()`. With these constructors, Testcontainers has traditionally used a default image name (including a fixed image tag/version). This has caused a conflict between the need to keep the defaults sane (i.e. up to date) and the need to avoid silently upgrading these dependencies along with new versions of Testcontainers. +* a single string-argument constructor, which has taken either a version or an image name as a String. This has caused some ambiguity and confusion. + +Since v1.15.0, both of these constructor types have been deprecated, for the reasons given above. + +Instead, it is highly recommended that _all containers_ be constructed using a constructor that accepts a `DockerImageName` object. +The `DockerImageName` class is an unambiguous reference to a docker image. + +It is suggested that developers treat `DockerImageName`s as you would any other potentially-constant value - consider defining a constant in your test codebase that matches the production version of the dependency you are using. + +### Examples + +A generic container rule can be used with any public docker image; for example: + + +[Creating a Redis container](../examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java) inside_block:container + + +The container, as `@Container`, will be started before any tests in the class run, and will be destroyed after all +tests have run. diff --git a/docs/usage/dockerfile.md b/docs/features/creating_images.md similarity index 60% rename from docs/usage/dockerfile.md rename to docs/features/creating_images.md index 88c3fda2d17..8a239d342cc 100644 --- a/docs/usage/dockerfile.md +++ b/docs/features/creating_images.md @@ -1,6 +1,6 @@ -# Dockerfile containers +# Creating images on-the-fly -## Benefits +## Overview In situations where there is no pre-existing Docker image, Testcontainers can create a new temporary image on-the-fly from a Dockerfile. For example, when the component under test is the Docker image itself, or when an existing base @@ -9,42 +9,52 @@ image needs to be customized for specific test(s). Simply pass a configured instance of `ImageFromDockerfile` as a constructor parameter to `GenericContainer`. Testcontainers will `docker build` a temporary container image, and will use it when creating the container. - -## Examples - -### Dockerfile from String, file or classpath resource +## Dockerfile from String, file or classpath resource `ImageFromDockerfile` accepts arbitrary files, strings or classpath resources to be used as files in the build context. At least one of these needs to be a `Dockerfile`. + ```java -@Rule -public GenericContainer dslContainer = new GenericContainer( +GenericContainer container = new GenericContainer( new ImageFromDockerfile() .withFileFromString("folder/someFile.txt", "hello") .withFileFromClasspath("test.txt", "mappable-resource/test-resource.txt") .withFileFromClasspath("Dockerfile", "mappable-dockerfile/Dockerfile")) ``` -The following methods may be used to provide the `Dockerfile` and any other required files: +The following methods may be used to provide the `Dockerfile` and any other required build context files: * `withFileFromString(buildContextPath, content)` * `withFileFromClasspath(buildContextPath, classpathPath)` * `withFileFromPath(buildContextPath, filesystemPath)` * `withFileFromFile(buildContextPath, filesystemFile)` -### Dockerfile DSL +!!! info + In older versions of Testcontainers (before 1.3.0) it was necessary to explicitly declare each file that needed to + be present in the Docker build context. + This can be replaced with the following syntax: + + [Passing an entire directory of files to the Dockerfile build context](../../core/src/test/java/org/testcontainers/images/builder/DockerfileBuildTest.java) inside_block:docsShowRecursiveFileInclusion + + + Where `RESOURCE_PATH` is the path to a directory containing a `Dockerfile` and any files that it needs to refer to. + Doing this is equivalent to `docker build RESOURCE_PATH` on the command line. + + To mimic `docker build .`, `RESOURCE_PATH` would simply be set to `.` as well. + +## Dockerfile DSL If a static Dockerfile is not sufficient (e.g. your test needs to cover many variations that are best generated programmatically), there is a DSL available to allow Dockerfiles to be defined in code. e.g.: ```java new GenericContainer( new ImageFromDockerfile() - .withDockerfileFromBuilder(builder -> { + .withDockerfileFromBuilder(builder -> builder - .from("alpine:3.2") + .from("alpine:3.17") .run("apk add --update nginx") .cmd("nginx", "-g", "daemon off;") - .build(); })) + .build())) .withExposedPorts(80); ``` @@ -63,4 +73,15 @@ By default the no-args constructor will use an image name of the form `testconta * `public ImageFromDockerfile()` * `public ImageFromDockerfile(String dockerImageName)` -* `public ImageFromDockerfile(String dockerImageName, boolean deleteOnExit)` \ No newline at end of file +* `public ImageFromDockerfile(String dockerImageName, boolean deleteOnExit)` + +## Alternative Dockerfiles + +Normally Docker will automatically build an image from any `/Dockerfile` that it finds in the root of the build context. +To override this behaviour, use `.withDockerfilePath("./Name-Of-Other-Dockerfile")`. + +## Build Args + +[Build Args](https://docs.docker.com/engine/reference/builder/#arg) may be used to allow lightweight parameterization. + +To specify build args, use `.withBuildArg("varname", "value")` or provide a `Map` of args using `.withBuildArgs(map)`. diff --git a/docs/features/files.md b/docs/features/files.md new file mode 100644 index 00000000000..d2c901678a6 --- /dev/null +++ b/docs/features/files.md @@ -0,0 +1,32 @@ +# Files and volumes + +## Copying files + +Files can be copied into the container before startup, or can be copied from the container after the container has started. + +!!! note + This is the recommended approach for portability cross-docker environments. + +### Copying to a container before startup + + +[Copying files using MountableFile](../../core/src/test/java/org/testcontainers/junit/CopyFileToContainerTest.java) inside_block:copyToContainer + + +Using `Transferable`, file content will be placed in the specified location. + + +[Copying files using Transferable](../../core/src/test/java/org/testcontainers/containers/GenericContainerTest.java) inside_block:transferableFile + + +Setting file mode is also possible. + + +[Copying files using Transferable with file mode](../../core/src/test/java/org/testcontainers/containers/GenericContainerTest.java) inside_block:transferableWithFileMode + + +### Copying a file from a running container + + +[Copying files from a container](../../core/src/test/java/org/testcontainers/junit/CopyFileToContainerTest.java) inside_block:copyFileFromContainer + diff --git a/docs/features/image_name_substitution.md b/docs/features/image_name_substitution.md new file mode 100644 index 00000000000..0948e9fa2c7 --- /dev/null +++ b/docs/features/image_name_substitution.md @@ -0,0 +1,155 @@ +# Image name substitution + +Testcontainers supports automatic substitution of Docker image names. + +This allows replacement of an image name specified in test code with an alternative name - for example, to replace the +name of a Docker Hub image dependency with an alternative hosted on a private image registry. + +This is advisable to avoid [Docker Hub rate limiting](../supported_docker_environment/image_registry_rate_limiting.md), and some companies will prefer this for policy reasons. + +This page describes four approaches for image name substitution: + +* [Manual substitution](#manual-substitution) - not relying upon an automated approach +* Using an Image Name Substitutor: + * [Developing a custom function for transforming image names on the fly](#developing-a-custom-function-for-transforming-image-names-on-the-fly) + * [Overriding image names individually in configuration](#overriding-image-names-individually-in-configuration) + +It is assumed that you have already set up a private registry hosting [all the Docker images your build requires](../supported_docker_environment/image_registry_rate_limiting.md#which-images-are-used-by-testcontainers). + + + + +## Manual substitution + +Consider this if: + +* You use only a few images and updating code is not a chore +* All developers and CI machines in your organisation have access to a common registry server +* You also use one of the automated mechanisms to substitute [the images that Testcontainers itself requires](../supported_docker_environment/image_registry_rate_limiting.md#which-images-are-used-by-testcontainers) + +This approach simply entails modifying test code manually, e.g. changing: + +For example, you may have a test that uses the `mysql` container image from Docker Hub: + + +[Direct Docker Hub image name](../examples/junit4/generic/src/test/java/generic/ImageNameSubstitutionTest.java) inside_block:directDockerHubReference + + +to: + + +[Private registry image name](../examples/junit4/generic/src/test/java/generic/ImageNameSubstitutionTest.java) inside_block:hardcodedMirror + + + + + + +## Automatically modifying Docker Hub image names + +Testcontainers can be configured to modify Docker Hub image names on the fly to apply a prefix string. + +Consider this if: + +* Developers and CI machines need to use different image names. For example, developers are able to pull images from Docker Hub, but CI machines need to pull from a private registry +* Your private registry has copies of images from Docker Hub where the names are predictable, and just adding a prefix is enough. + For example, `registry.mycompany.com/mirror/mysql:8.0.36` can be derived from the original Docker Hub image name (`mysql:8.0.36`) with a consistent prefix string: `registry.mycompany.com/mirror/` + +In this case, image name references in code are **unchanged**. +i.e. you would leave as-is: + + +[Unchanged direct Docker Hub image name](../examples/junit4/generic/src/test/java/generic/ImageNameSubstitutionTest.java) inside_block:directDockerHubReference + + +You can then configure Testcontainers to apply the prefix `registry.mycompany.com/mirror/` to every image that it tries to pull from Docker Hub. +This can be done in one of two ways: + +* Setting environment variables `TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX=registry.mycompany.com/mirror/` +* Via config file, setting `hub.image.name.prefix` in either: + * the `~/.testcontainers.properties` file in your user home directory, or + * a file named `testcontainers.properties` on the classpath + +Testcontainers will automatically apply the prefix to every image that it pulls from Docker Hub - please verify that all [the required images](../supported_docker_environment/image_registry_rate_limiting.md#which-images-are-used-by-testcontainers) exist in your registry. + +Testcontainers will not apply the prefix to: + +* non-Hub image names (e.g. where another registry is set) +* Docker Hub image names where the hub registry is explicitly part of the name (i.e. anything with a `docker.io` or `registry.hub.docker.com` host part) + + + +## Developing a custom function for transforming image names on the fly + +Consider this if: + +* You have complex rules about which private registry images should be used as substitutes, e.g.: + * non-deterministic mapping of names meaning that a [name prefix](#automatically-modifying-docker-hub-image-names) cannot be used + * rules depending upon developer identity or location +* or you wish to add audit logging of images used in the build +* or you wish to prevent accidental usage of images that are not on an approved list + +In this case, image name references in code are **unchanged**. +i.e. you would leave as-is: + + +[Unchanged direct Docker Hub image name](../examples/junit4/generic/src/test/java/generic/ImageNameSubstitutionTest.java) inside_block:directDockerHubReference + + +You can implement a custom image name substitutor by: + +* subclassing `org.testcontainers.utility.ImageNameSubstitutor` +* configuring Testcontainers to use your custom implementation + +The following is an example image substitutor implementation: + + +[Example Image Substitutor](../examples/junit4/generic/src/test/java/generic/ExampleImageNameSubstitutor.java) block:ExampleImageNameSubstitutor + + +Testcontainers can be configured to find it at runtime via configuration. +To do this, create or modify a file on the classpath named `testcontainers.properties`. + +For example: + +=== "`src/test/resources/testcontainers.properties`" + ```text + image.substitutor=com.mycompany.testcontainers.ExampleImageNameSubstitutor + ``` + +Note that it is also possible to provide this same configuration property: + +* in a `testcontainers.properties` file at the root of a library JAR file (useful if you wish to distribute a drop-in image substitutor JAR within an organization) +* in a properties file in the user's home directory (`~/.testcontainers.properties`; note the leading `.`) +* or as an environment variable (e.g. `TESTCONTAINERS_IMAGE_SUBSTITUTOR=com.mycompany.testcontainers.ExampleImageNameSubstitutor`). + +Please see [the documentation on configuration mechanisms](./configuration.md) for more information. + +Also, you can use the `ServiceLoader` mechanism to provide the fully qualified class name of the `ImageNameSubstitutor` implementation: + +=== "`src/test/resources/META-INF/services/org.testcontainers.utility.ImageNameSubstitutor`" + ```text + com.mycompany.testcontainers.ExampleImageNameSubstitutor + ``` + + +## Overriding image names individually in configuration + +!!! note + This approach is discouraged and deprecated, but is documented for completeness. + Please consider one of the other approaches outlined in this page instead. + Overriding individual image names via configuration may be removed in the future. + +Consider this if: + +* You have many references to image names in code and changing them is impractical, and +* None of the other options are practical for you + +In this case, image name references in code are left **unchanged**. +i.e. you would leave as-is: + + +[Unchanged direct Docker Hub image name](../examples/junit4/generic/src/test/java/generic/ImageNameSubstitutionTest.java) inside_block:directDockerHubReference + + +You can force Testcontainers to substitute in a different image [using a configuration file](./configuration.md), which allows some (but not all) container names to be substituted. diff --git a/docs/features/jib.md b/docs/features/jib.md new file mode 100644 index 00000000000..c0be0188b07 --- /dev/null +++ b/docs/features/jib.md @@ -0,0 +1,11 @@ +# Using Jib + +[Jib](https://github.com/GoogleContainerTools/jib/tree/master/jib-core) is a library for building Docker images. +You can use it as an alternative to Testcontainers default `DockerfileBuilder`. + + +[GenericContainer with JibImage](../../core/src/test/java/org/testcontainers/containers/JibTest.java) inside_block:jibContainerUsage + + +!!! hint +The Testcontainers library JAR will not automatically add a `jib-core` JAR to your project. Minimum version required is `com.google.cloud.tools:jib-core:0.22.0`. diff --git a/docs/features/networking.md b/docs/features/networking.md new file mode 100644 index 00000000000..7ecf929da16 --- /dev/null +++ b/docs/features/networking.md @@ -0,0 +1,94 @@ +# Networking and communicating with containers + +## Exposing container ports to the host + +It is common to want to connect to a container from your test process, running on the test 'host' machine. +For example, you may be testing a class that needs to connect to a backend or data store container. + +Generally, each required port needs to be explicitly exposed. For example, we can specify one or more ports as follows: + + +[Exposing ports](../examples/junit4/generic/src/test/java/generic/MultiplePortsExposedTest.java) inside_block:rule + + +Note that this exposed port number is from the *perspective of the container*. + +*From the host's perspective* Testcontainers actually exposes this on a random free port. +This is by design, to avoid port collisions that may arise with locally running software or in between parallel test runs. + +Because there is this layer of indirection, it is necessary to ask Testcontainers for the actual mapped port at runtime. +This can be done using the `getMappedPort` method, which takes the original (container) port as an argument: + + +[Retrieving actual ports at runtime](../examples/junit4/generic/src/test/java/generic/MultiplePortsExposedTest.java) inside_block:fetchPortsByNumber + + +!!! warning + Because the randomised port mapping happens during container startup, the container must be running at the time `getMappedPort` is called. + You may need to ensure that the startup order of components in your tests caters for this. + +There is also a `getFirstMappedPort` method for convenience, for the fairly common scenario of a container that only exposes one port: + + +[Retrieving the first mapped port](../examples/junit4/generic/src/test/java/generic/MultiplePortsExposedTest.java) inside_block:fetchFirstMappedPort + + +## Getting the container host + +When running with a local Docker daemon, exposed ports will usually be reachable on `localhost`. +However, in some CI environments they may instead be reachable on a different host. + +As such, Testcontainers provides a convenience method to obtain an address on which the container should be reachable from the host machine. + + +[Getting the container host](../examples/junit4/generic/src/test/java/generic/MultiplePortsExposedTest.java) inside_block:getHostOnly + + +It is normally advisable to use `getHost` and `getMappedPort` together when constructing addresses - for example: + + +[Getting the container host and mapped port](../examples/junit4/generic/src/test/java/generic/MultiplePortsExposedTest.java) inside_block:getHostAndMappedPort + + +!!! tip + `getHost()` is a replacement for `getContainerIpAddress()` and returns the same result. + `getContainerIpAddress()` is believed to be confusingly named, and will eventually be deprecated. + +## Exposing host ports to the container + +In some cases it is necessary to make a network connection from a container to a socket that is listening on the host machine. +Natively, Docker has limited support for this model across platforms. +Testcontainers, however, makes this possible. + +In this example, assume that `localServerPort` is a port on our test host machine where a server (e.g. a web application) is running. + +We need to tell Testcontainers to prepare to expose this port to containers: + + +[Exposing the host port](../examples/junit4/generic/src/test/java/generic/HostPortExposedTest.java) inside_block:exposePort + + +!!! warning + Note that the above command should be invoked _before_ containers are started, but _after_ the server on the host was started. + Alternatively, use `container.withAccessToHost(true)` to force the host access mechanism (you still need to call `exposeHostPorts` to make the port available). + +Having done so, we can now access this port from any containers that are launched. +From a container's perspective, the hostname will be `host.testcontainers.internal` and the port will be the same value as `localServerPort`. + +For example, here we construct an HTTP URL for our local web application and tell a Selenium container to get a page from it: + + +[Accessing the exposed host port from a container](../examples/junit4/generic/src/test/java/generic/HostPortExposedTest.java) inside_block:useHostExposedPort + + + +## Advanced networking + +Docker provides the ability for you to create custom networks and place containers on one or more networks. Then, communication can occur between networked containers without the need of exposing ports through the host. With Testcontainers, you can do this as well. + +!!! warning + Note that Testcontainers currently only allows a container to be on a single network. + + +[Creating custom networks](../../core/src/test/java/org/testcontainers/containers/NetworkTest.java) inside_block:useCustomNetwork + diff --git a/docs/features/reuse.md b/docs/features/reuse.md new file mode 100644 index 00000000000..884f3d5ac33 --- /dev/null +++ b/docs/features/reuse.md @@ -0,0 +1,37 @@ +# Reusable Containers (Experimental) + +!!! warning + Reusable Containers is still an experimental feature and the behavior can change. + Those containers won't stop after all tests are finished. + +The *Reusable* feature keeps the containers running and next executions with the same container configuration +will reuse it. To use it, start the container manually by calling `start()` method, do not call `stop()` method +directly or indirectly via `try-with-resources` or `JUnit integration`, and enable it manually through an +opt-in mechanism per environment. To reuse a container, the container configuration **must be the same**. + +!!! note + Reusable containers are not suited for CI usage and as an experimental feature + not all Testcontainers features are fully working (e.g., resource cleanup + or networking). + +## How to use it + +* Enable `Reusable Containers` + * through environment variable `TESTCONTAINERS_REUSE_ENABLE=true` + * through user property file `~/.testcontainers.properties`, by adding `testcontainers.reuse.enable=true` + * **not** through classpath properties file [see this comment](https://github.com/testcontainers/testcontainers-java/issues/5364#issuecomment-1125907734) +* Define a container and subscribe to reuse the container using `withReuse(true)` + +```java +GenericContainer container = new GenericContainer("redis:6-alpine") + .withExposedPorts(6379) + .withReuse(true) +``` + +* Start the container manually by using `container.start()` + +### Reusable Container with Testcontainers JDBC URL + +If using the [Testcontainers JDBC URL support](../../modules/databases/jdbc#database-containers-launched-via-jdbc-url-scheme) +the URL **must** follow the pattern of `jdbc:tc:mysql:8.0.36:///databasename?TC_REUSABLE=true`. +`TC_REUSABLE=true` is set as a parameter of the JDBC URL. diff --git a/docs/features/startup_and_waits.md b/docs/features/startup_and_waits.md new file mode 100644 index 00000000000..d760dfa93d0 --- /dev/null +++ b/docs/features/startup_and_waits.md @@ -0,0 +1,133 @@ +# Waiting for containers to start or be ready + +!!! info "Wait strategies vs Startup strategies" + + **Wait strategy:** is the container in a state that is useful for testing. This is generally approximated as 'can we talk to this container over the network'. However, there are quite a few variations and nuances. + + **Startup strategy:** did a container reach the desired running state. *Almost always* this just means 'wait until the container is running' - for a daemon process in a container this is the goal. Sometimes we need to wait until the container reaches a running state and then exits - this is the 'one shot startup' strategy, only used for cases where we need to run a one off command in a container but not a daemon. + + +## Wait Strategies + +Ordinarily Testcontainers will wait for up to 60 seconds for the container's first mapped network port to start listening. + +This simple measure provides a basic check whether a container is ready for use. + + +[Waiting for the first exposed port to start listening](../examples/junit4/generic/src/test/java/generic/WaitStrategiesTest.java) inside_block:waitForNetworkListening + + +If the default 60s timeout is not sufficient, it can be altered with the `withStartupTimeout()` method. + +If waiting for a listening TCP port is not sufficient to establish whether the container is ready, you can use the +`waitingFor()` method with other [`WaitStrategy`](http://static.javadoc.io/org.testcontainers/testcontainers/{{ latest_version }}/org/testcontainers/containers/wait/strategy/WaitStrategy.html) implementations as shown below. + +### HTTP Wait strategy examples + +You can choose to wait for an HTTP(S) endpoint to return a particular status code. + +#### Waiting for 200 OK + +[](../examples/junit4/generic/src/test/java/generic/WaitStrategiesTest.java) inside_block:waitForSimpleHttp + + +Variations on the HTTP wait strategy are supported, including: + +#### Waiting for multiple possible status codes + +[](../examples/junit4/generic/src/test/java/generic/WaitStrategiesTest.java) inside_block:waitForHttpWithMultipleStatusCodes + + +#### Waiting for a status code that matches a predicate + +[Waiting for a status code that matches a predicate](../examples/junit4/generic/src/test/java/generic/WaitStrategiesTest.java) inside_block:waitForHttpWithStatusCodePredicate + + +#### Using TLS + +[](../examples/junit4/generic/src/test/java/generic/WaitStrategiesTest.java) inside_block:waitForHttpWithTls + + +### Healthcheck Wait strategy examples + +If the used image supports Docker's [Healthcheck](https://docs.docker.com/engine/reference/builder/#healthcheck) feature, you can directly leverage the `healthy` state of the container as your wait condition: + + +[](../examples/junit4/generic/src/test/java/generic/WaitStrategiesTest.java) inside_block:healthcheckWait + + +### Log output Wait Strategy + +In some situations a container's log output is a simple way to determine if it is ready or not. +For example, we can wait for a `Ready' message in the container's logs as follows: + + +[](../examples/junit4/generic/src/test/java/generic/WaitStrategiesTest.java) inside_block:logMessageWait + + +### Other Wait Strategies + +For further options, check out the [`Wait`](http://static.javadoc.io/org.testcontainers/testcontainers/{{ latest_version }}/org/testcontainers/containers/wait/strategy/Wait.html) convenience class, or the various subclasses of [`WaitStrategy`](http://static.javadoc.io/org.testcontainers/testcontainers/{{ latest_version }}/org/testcontainers/containers/wait/strategy/WaitStrategy.html). + +If none of these options meet your requirements, you can create your own subclass of +[`AbstractWaitStrategy`](http://static.javadoc.io/org.testcontainers/testcontainers/{{ latest_version }}/org/testcontainers/containers/wait/strategy/AbstractWaitStrategy.html) with an +appropriate wait mechanism in `waitUntilReady()`. +The `GenericContainer.waitingFor()` method accepts any valid [`WaitStrategy`](http://static.javadoc.io/org.testcontainers/testcontainers/{{ latest_version }}/org/testcontainers/containers/wait/strategy/WaitStrategy.html). + + +## Startup check Strategies + +Ordinarily Testcontainers will check that the container has reached the running state and has not exited. +In order to do that inspect is executed against the container and state parameter is extracted. + +All logic is implemented in [`StartupCheckStrategy`](http://static.javadoc.io/org.testcontainers/testcontainers/{{ latest_version }}/org/testcontainers/containers/startupcheck/StartupCheckStrategy.html) child classes. + +### Running startup strategy example + +This is the strategy used by default. Testcontainers just checks if container is running. + +Implemented in [`IsRunningStartupCheckStrategy`](http://static.javadoc.io/org.testcontainers/testcontainers/{{ latest_version }}/org/testcontainers/containers/startupcheck/IsRunningStartupCheckStrategy.html) class. + +### One shot startup strategy example + +This strategy is intended for use with containers that only run briefly and exit of their own accord. As such, success is deemed to be when +the container has stopped with exit code 0. + + +[Using one shot startup strategy](../examples/junit4/generic/src/test/java/org/testcontainers/containers/startupcheck/StartupCheckStrategyTest.java) inside_block:withOneShotStrategy + + +### Indefinite one shot startup strategy example + +Variant of one shot strategy that does not impose a timeout. Intended for situation such as when a long running task forms part of + container startup. + +It has to be assumed that the container will stop of its own accord, either with a success or failure exit code. + + +[Using indefinite one shot startup strategy](../examples/junit4/generic/src/test/java/org/testcontainers/containers/startupcheck/StartupCheckStrategyTest.java) inside_block:withIndefiniteOneShotStrategy + + +### Minimum duration startup strategy example + +Checks that the container is running and has been running for a defined minimum period of time. + + +[Using minimum duration strategy](../examples/junit4/generic/src/test/java/org/testcontainers/containers/startupcheck/StartupCheckStrategyTest.java) inside_block:withMinimumDurationStrategy + + +### Other startup strategies + +If none of these options meet your requirements, you can create your own subclass of +[`StartupCheckStrategy`](http://static.javadoc.io/org.testcontainers/testcontainers/{{ latest_version }}/org/testcontainers/containers +/startupcheck/StartupCheckStrategy.html) with an appropriate startup check mechanism in `waitUntilStartupSuccessful()`. +Or you can leave it as is and just implement the `checkStartupState(DockerClient dockerClient, String containerId)` if you still want to check state + periodically. + +## Depending on another container + +Sometimes, a container relies on another container to be ready before it should start itself. An example of this might be a database that needs to be started before your application container can link to it. You can tell a container that it depends on another container by using the `dependsOn` method: + + +[Depending on another container](../examples/junit4/generic/src/test/java/generic/DependsOnTest.java) inside_block:dependsOn + diff --git a/docs/getting_help.md b/docs/getting_help.md new file mode 100644 index 00000000000..e7fc053948d --- /dev/null +++ b/docs/getting_help.md @@ -0,0 +1,10 @@ +# Getting help + +We hope that you find Testcontainers intuitive to use and reliable. +However, sometimes things don't go the way we'd expect, and we'd like to try and help out if we can. + +To contact the Testcontainers team and other users you can: + +* Join our [Slack team](https://slack.testcontainers.org) +* [Search our issues tracker](https://github.com/testcontainers/testcontainers-java/issues), or raise a new issue if you find any bugs or have suggested improvements +* [Search Stack Overflow](https://stackoverflow.com/questions/tagged/testcontainers), especially among posts tagged with `testcontainers` diff --git a/docs/icons/github.svg b/docs/icons/github.svg new file mode 100644 index 00000000000..d563c83aa0a --- /dev/null +++ b/docs/icons/github.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/docs/icons/slack.svg b/docs/icons/slack.svg new file mode 100644 index 00000000000..942265e1d23 --- /dev/null +++ b/docs/icons/slack.svg @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/docs/icons/stackoverflow.svg b/docs/icons/stackoverflow.svg new file mode 100644 index 00000000000..c3307627c4e --- /dev/null +++ b/docs/icons/stackoverflow.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/docs/icons/twitter.svg b/docs/icons/twitter.svg new file mode 100644 index 00000000000..ef3fbc6217e --- /dev/null +++ b/docs/icons/twitter.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index db99e34c2ef..40304dd22ed 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,52 +1,241 @@ -# TestContainers +# Testcontainers for Java + +

Not using Java? Here are other supported languages!

+
+ Java + Go + .NET + Node.js + Python + Rust + Haskell + Ruby +
+ +## About Testcontainers for Java + +*Testcontainers for Java* is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container. + +Testcontainers make the following kinds of tests easier: + +* **Data access layer integration tests**: use a containerized instance of a MySQL, PostgreSQL or Oracle database to test your data access layer code for complete compatibility, but without requiring complex setup on developers' machines and safe in the knowledge that your tests will always start with a known DB state. Any other database type that can be containerized can also be used. +* **Application integration tests**: for running your application in a short-lived test mode with dependencies, such as databases, message queues or web servers. +* **UI/Acceptance tests**: use [containerized web browsers](modules/webdriver_containers.md), compatible with Selenium, for conducting automated UI tests. Each test can get a fresh instance of the browser, with no browser state, plugin variations or automated browser upgrades to worry about. And you get a video recording of each test session, or just each session where tests failed. +* **Much more!** Check out the various contributed modules or create your own custom container classes using [`GenericContainer`](features/creating_container.md) as a base. + +## Prerequisites + +* Docker - please see [General Docker requirements](supported_docker_environment/index.md) +* A supported JVM testing framework: + * [JUnit 4](test_framework_integration/junit_4.md) - See the [JUnit 4 Quickstart Guide](quickstart/junit_4_quickstart.md) + * [Jupiter/JUnit 5](test_framework_integration/junit_5.md) + * [Spock](test_framework_integration/spock.md) + * *Or* manually add code to control the container/test lifecycle (See [hints for this approach](test_framework_integration/junit_4.md#manually-controlling-container-lifecycle)) + +## Maven dependencies + +Testcontainers is distributed as separate JARs with a common version number: + +* A core JAR file for core functionality, generic containers and docker-compose support +* A separate JAR file for each of the specialised modules. Each module's documentation describes the Maven/Gradle dependency to add to your project's build. + +For the core library, the latest Maven/Gradle dependency is as follows: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers + {{latest_version}} + test + + ``` + +You can also [check the latest version available on Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22org.testcontainers%22). + +### Managing versions for multiple Testcontainers dependencies + +To avoid specifying the version of each dependency, you can use a `BOM` or `Bill Of Materials`. + +Using Maven you can add the following to `dependencyManagement` section in your `pom.xml`: +=== "Maven" +```xml + + + + org.testcontainers + testcontainers-bom + {{latest_version}} + pom + import + + + +``` + +and then use dependencies without specifying a version: +=== "Maven" + ```xml + + org.testcontainers + testcontainers-mysql + test + + ``` + +Using Gradle 5.0 or higher, you can add the following to the `dependencies` section in your `build.gradle`: + +=== "Gradle" + ```groovy + implementation platform('org.testcontainers:testcontainers-bom:{{latest_version}}') //import bom + testImplementation('org.testcontainers:testcontainers-mysql') //no version specified + ``` + + +[JitPack](jitpack_dependencies.md) builds are available for pre-release versions. + +!!! warning "Shaded dependencies" + Testcontainers depends on other libraries (like docker-java) for it to work. + Some of them (JUnit, docker-java-{api,transport} and its transitive dependencies, JNA, visible-assertions and others) are part of our public API. + But there are also "private", implementation detail dependencies (e.g., docker-java-core, Guava, OkHttp, etc.) that are not exposed to public API but prone to conflicts with test code/application under test code. + As such, **these libraries are 'shaded' into the core Testcontainers JAR** and relocated under `org.testcontainers.shaded` to prevent class conflicts. + +## Sponsors + +A huge thank you to our sponsors: + +### Bronze sponsors + + + + + + + + + + + +### Donors + + + + + +### Backers + +* [Philip Riecks (@rieckpil)](https://github.com/rieckpil) +* [Karl Heinz Marbaise (@khmarbaise)](https://github.com/khmarbaise) +* [Sascha Frinken (@sascha-frinken)](https://github.com/sascha-frinken) +* [Christoph Dreis (@dreis2211)](https://github.com/dreis2211) +* [Nikita Zhevnitskiy (@zhenik)](https://github.com/zhenik) +* [Bas Stoker (@bastoker)](https://github.com/bastoker) +* [Oleg Nenashev (@oleg-nenashev)](https://github.com/oleg-nenashev) +* [Rik Glover (@rikglover)](https://github.com/rikglover) +* [Amitosh Swain Mahapatra (@recrsn)](https://github.com/recrsn) +* [Paris Apostolopoulos](https://opencollective.com/paris-apostolopoulos) + +## Who is using Testcontainers? + +* [ZeroTurnaround](https://zeroturnaround.com) - Testing of the Java Agents, micro-services, Selenium browser automation +* [Zipkin](https://zipkin.io) - MySQL and Cassandra testing +* [Apache Gora](https://gora.apache.org) - CouchDB testing +* [Apache James](https://james.apache.org) - LDAP and Cassandra integration testing +* [StreamSets](https://github.com/streamsets/datacollector) - LDAP, MySQL Vault, MongoDB, Redis integration testing +* [Playtika](https://github.com/Playtika/testcontainers-spring-boot) - Kafka, Couchbase, MariaDB, Redis, Neo4j, Aerospike, MemSQL +* [JetBrains](https://www.jetbrains.com/) - Testing of the TeamCity plugin for HashiCorp Vault +* [Plumbr](https://plumbr.io) - Integration testing of data processing pipeline micro-services +* [Streamlio](https://streaml.io/) - Integration and Chaos Testing of our fast data platform based on Apache Pulsar, Apache BookKeeper and Apache Heron. +* [Spring Session](https://projects.spring.io/spring-session/) - Redis, PostgreSQL, MySQL and MariaDB integration testing +* [Apache Camel](https://camel.apache.org) - Testing Camel against native services such as Consul, Etcd and so on +* [Infinispan](https://infinispan.org) - Testing the Infinispan Server as well as integration tests with databases, LDAP and KeyCloak +* [Instana](https://www.instana.com) - Testing agents and stream processing backends +* [eBay Marketing](https://www.ebay.com) - Testing for MySQL, Cassandra, Redis, Couchbase, Kafka, etc. +* [Skyscanner](https://www.skyscanner.net/) - Integration testing against HTTP service mocks and various data stores +* [Neo4j-OGM](https://neo4j.com/developer/neo4j-ogm/) - Testing with Neo4j +* [Spring Data Neo4j](https://github.com/spring-projects/spring-data-neo4j/) - Testing imperative and reactive implementations with Neo4j +* [Lightbend](https://www.lightbend.com/) - Testing [Alpakka Kafka](https://doc.akka.io/docs/alpakka-kafka/current/) and support in [Alpakka Kafka Testkit](https://doc.akka.io/docs/alpakka-kafka/current/testing.html#testing-with-kafka-in-docker) +* [Zalando SE](https://corporate.zalando.com/en) - Testing core business services +* [Europace AG](https://tech.europace.de/) - Integration testing for databases and micro services +* [Micronaut Data](https://github.com/micronaut-projects/micronaut-data/) - Testing of Micronaut Data JDBC, a database access toolkit +* [Vert.x SQL Client](https://github.com/eclipse-vertx/vertx-sql-client) - Testing with PostgreSQL, MySQL, MariaDB, SQL Server, etc. +* [JHipster](https://www.jhipster.tech/) - Couchbase and Cassandra integration testing +* [wescale](https://www.wescale.com) - Integration testing against HTTP service mocks and various data stores +* [Marquez](https://marquezproject.github.io/marquez) - PostgreSQL integration testing +* [Wise (formerly TransferWise)](https://wise.com) - Integration testing for different RDBMS, kafka and micro services +* [XWiki](https://xwiki.org) - [Testing XWiki](https://dev.xwiki.org/xwiki/bin/view/Community/Testing/DockerTesting/) under all [supported configurations](https://dev.xwiki.org/xwiki/bin/view/Community/SupportStrategy/) +* [Apache SkyWalking](http://github.com/apache/skywalking) - End-to-end testing of the Apache SkyWalking, and plugin tests of its subproject, [Apache SkyWalking Python](http://github.com/apache/skywalking-python), and of its eco-system built by the community, like [SkyAPM NodeJS Agent](http://github.com/SkyAPM/nodejs) +* [jOOQ](https://www.jooq.org) - Integration testing all of jOOQ with a variety of RDBMS +* [Trino (formerly Presto SQL)](https://trino.io) - Integration testing all Trino core & connectors, including tests of multi-node deployments and security configurations. +* Google - Various open source projects: [OpenTelemetry](https://github.com/GoogleCloudPlatform/opentelemetry-operations-java), [Universal Application Tool](https://github.com/seattle-uat/universal-application-tool), [CloudBowl](https://github.com/GoogleCloudPlatform/cloudbowl-microservice-game) +* [Backbase](https://www.backbase.com/) - Unit, Integration and Acceptance testing for different the databases supported (Oracle, SQL Server, MySQL), the different messaging systems supported (Kafka, Rabbit, AMQ) and other microservices and HTTP mocks. +* [CloudBees](https://www.cloudbees.com/) - Integration testing of products, including but not limited to database and AWS/Localstack integration testing. +* [Jenkins](https://www.jenkins.io/) - Integration testing of multiple plugins and the Trilead SSH2 fork maintained by the Jenkins community + ([query](https://github.com/search?l=Maven+POM&q=org%3Ajenkinsci+testcontainers&type=Code)). +* [Elastic](https://www.elastic.co) - Integration testing of the Java APM agent +* [Alkira](https://www.alkira.com/) - Testing of multiple micro-services using Kafka, PostgreSQL, Apache Zookeeper, Etcd and so on. +* [Togglz](https://www.togglz.org/) - Feature Flags for the Java platform +* [Byzer](https://www.byzer.org/home) - Integration tests for Data and AI platforms are based on multiple versions of Byzer, Ray and Apache Spark. +* [Apache SeaTunnel](https://github.com/apache/incubator-seatunnel) - Integration testing with different datasource. +* [Bucket4j](https://github.com/bucket4j/bucket4j) - Java rate-limiting library based on the token-bucket algorithm. +* [Spark ClickHouse Connector](https://github.com/housepower/spark-clickhouse-connector) - Integration tests for Apache Spark with both single node ClickHouse instance and multi-node ClickHouse cluster. +* [Quarkus](https://github.com/quarkusio/quarkus) - Testcontainers is used extensively for Quarkus' [DevServices](https://quarkus.io/guides/dev-services) feature. +* [Apache Kyuubi](https://kyuubi.apache.org) - Integration testing with Trino as data source engine, Kafka, etc. +* [Dash0](https://www.dash0.com) - Integration testing for OpenTelemetry Observability product. -> TestContainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container. - -[![Build Status](https://travis-ci.org/testcontainers/testcontainers-java.svg?branch=master)](https://travis-ci.org/testcontainers/testcontainers-java) -[View on Github](https://github.com/testcontainers/testcontainers-java) - -![Testcontainers logo](https://rnorth.org/public/testcontainers/logo.png) - -## Use Cases - -TestContainers makes it easy to launch useful Docker containers for the duration of JUnit tests. - - * **Data access layer integration tests**: use a containerized instance of a MySQL, PostgreSQL or Oracle database to test your data access layer code for complete compatibility, but without requiring complex setup on developers' machines and safe in the knowledge that your tests will always start with a known DB state. Any other database type that can be containerized can also be used. - * **Application integration tests**: for running your application in a short-lived test mode with dependencies, such as databases, message queues or web servers. - * **UI/Acceptance tests**: use containerized web browsers, compatible with Selenium, for conducting automated UI tests. Each test can get a fresh instance of the browser, with no browser state, plugin variations or automated browser upgrades to worry about. And you get a video recording of each test session, or just each session where tests failed. - -## Who is using TestContainers? - - * [ZeroTurnaround](https://zeroturnaround.com) - Testing of the Java Agents, micro-services, Selenium browser automation - * [Zipkin](http://zipkin.io) - MySQL and Cassandra testing - * [Apache Gora](https://gora.apache.org) - CouchDB testing - * [Apache James](https://james.apache.org) - LDAP and Cassandra integration testing - * [StreamSets](https://github.com/streamsets/datacollector) - LDAP, MySQL Vault, MongoDB, Redis integration testing ## License -See [LICENSE](https://raw.githubusercontent.com/testcontainers/testcontainers-java/master/LICENSE). +See [LICENSE](https://raw.githubusercontent.com/testcontainers/testcontainers-java/main/LICENSE). ## Attributions This project includes a modified class (ScriptUtils) taken from the Spring JDBC project, adapted under the terms of the Apache license. Copyright for that class remains with the original authors. -This project was initially inspired by a [gist](https://gist.github.com/mosheeshel/c427b43c36b256731a0b) by Mosche Eschel. - -## Contributing - -* Star the project on [Github](https://github.com/testcontainers/testcontainers-java) and help spread the word :) -* Join our Slack: http://slack.testcontainers.org -* See [ROADMAP](ROADMAP.md) to understand the approach behind the project and what may/may not be in store for the future. -* [Post an issue](https://github.com/testcontainers/testcontainers-java/issues) if you find any bugs -* Contribute improvements or fixes using a [Pull Request](https://github.com/testcontainers/testcontainers-java/pulls). If you're going to contribute, thank you! Please just be sure to: - * discuss with the authors on an issue ticket prior to doing anything big - * follow the style, naming and structure conventions of the rest of the project - * make commits atomic and easy to merge - * verify all tests are passing. Build the project with `mvn clean install -Pproprietary-deps` to do this. +This project was initially inspired by a [gist](https://gist.github.com/mosheeshel/c427b43c36b256731a0b) by [Moshe Eshel](https://github.com/mosheeshel). ## Copyright -Copyright (c) 2015, 2016 Richard North and other authors. +Copyright (c) 2015-2021 Richard North and other authors. -See [AUTHORS](https://raw.githubusercontent.com/testcontainers/testcontainers-java/master/AUTHORS) for contributors. +See [AUTHORS](https://raw.githubusercontent.com/testcontainers/testcontainers-java/main/AUTHORS) for contributors. diff --git a/docs/jitpack_dependencies.md b/docs/jitpack_dependencies.md new file mode 100644 index 00000000000..30687dcd3bc --- /dev/null +++ b/docs/jitpack_dependencies.md @@ -0,0 +1,53 @@ +# JitPack (unreleased versions) + +If you like to live on the bleeding edge, [jitpack.io](https://jitpack.io) can be used to obtain SNAPSHOT versions. +Use the following dependency description instead: + +=== "Gradle" + ```groovy + testImplementation "com.github.testcontainers.testcontainers-java:--artifact name--:main-SNAPSHOT" + ``` +=== "Maven" + ```xml + + com.github.testcontainers.testcontainers-java + --artifact name-- + main-SNAPSHOT + + ``` + +A specific git revision (such as `02782d9`) can be used as a fixed version instead: + +=== "Gradle" + ```groovy + testImplementation "com.github.testcontainers.testcontainers-java:--artifact name--:02782d9" + ``` +=== "Maven" + ```xml + + com.github.testcontainers.testcontainers-java + --artifact name-- + 02782d9 + + ``` + + +The JitPack maven repository must also be declared, e.g.: + +=== "Gradle" + ```groovy + repositories { + maven { + url "https://jitpack.io" + } + } + ``` +=== "Maven" + ```xml + + + jitpack.io + https://jitpack.io + + + ``` diff --git a/docs/js/tc-header.js b/docs/js/tc-header.js new file mode 100644 index 00000000000..4186b6ca59b --- /dev/null +++ b/docs/js/tc-header.js @@ -0,0 +1,45 @@ +const mobileToggle = document.getElementById("mobile-menu-toggle"); +const mobileSubToggle = document.getElementById("mobile-submenu-toggle"); +function toggleMobileMenu() { + document.body.classList.toggle('mobile-menu'); + document.body.classList.toggle("mobile-tc-header-active"); +} +function toggleMobileSubmenu() { + document.body.classList.toggle('mobile-submenu'); +} +if (mobileToggle) + mobileToggle.addEventListener("click", toggleMobileMenu); +if (mobileSubToggle) + mobileSubToggle.addEventListener("click", toggleMobileSubmenu); + +const allParentMenuItems = document.querySelectorAll("#site-header .menu-item.has-children"); +function clearActiveMenuItem() { + document.body.classList.remove("tc-header-active"); + allParentMenuItems.forEach((item) => { + item.classList.remove("active"); + }); +} +function setActiveMenuItem(e) { + clearActiveMenuItem(); + e.currentTarget.closest(".menu-item").classList.add("active"); + document.body.classList.add("tc-header-active"); +} +allParentMenuItems.forEach((item) => { + const trigger = item.querySelector(":scope > a, :scope > button"); + + trigger.addEventListener("click", (e) => { + if (e.currentTarget.closest(".menu-item").classList.contains("active")) { + clearActiveMenuItem(); + } else { + setActiveMenuItem(e); + } + }); + + trigger.addEventListener("mouseenter", (e) => { + setActiveMenuItem(e); + }); + + item.addEventListener("mouseleave", (e) => { + clearActiveMenuItem(); + }); +}); \ No newline at end of file diff --git a/docs/language-logos/dotnet.svg b/docs/language-logos/dotnet.svg new file mode 100644 index 00000000000..2fb163d4d40 --- /dev/null +++ b/docs/language-logos/dotnet.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/docs/language-logos/go.svg b/docs/language-logos/go.svg new file mode 100644 index 00000000000..bfcca48ceda --- /dev/null +++ b/docs/language-logos/go.svg @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/docs/language-logos/haskell.svg b/docs/language-logos/haskell.svg new file mode 100644 index 00000000000..eb6de3776ec --- /dev/null +++ b/docs/language-logos/haskell.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/language-logos/java.svg b/docs/language-logos/java.svg new file mode 100644 index 00000000000..590da12d96a --- /dev/null +++ b/docs/language-logos/java.svg @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/docs/language-logos/nodejs.svg b/docs/language-logos/nodejs.svg new file mode 100644 index 00000000000..60091b1b759 --- /dev/null +++ b/docs/language-logos/nodejs.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/docs/language-logos/python.svg b/docs/language-logos/python.svg new file mode 100644 index 00000000000..d06a313c96b --- /dev/null +++ b/docs/language-logos/python.svg @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/docs/language-logos/ruby.svg b/docs/language-logos/ruby.svg new file mode 100644 index 00000000000..05537cedf72 --- /dev/null +++ b/docs/language-logos/ruby.svg @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/language-logos/rust.svg b/docs/language-logos/rust.svg new file mode 100644 index 00000000000..89039333533 --- /dev/null +++ b/docs/language-logos/rust.svg @@ -0,0 +1,57 @@ + + + \ No newline at end of file diff --git a/docs/logo.png b/docs/logo.png index 696e03ad6ff..88961b3e3f0 100644 Binary files a/docs/logo.png and b/docs/logo.png differ diff --git a/docs/logo.svg b/docs/logo.svg new file mode 100644 index 00000000000..bac0c391a59 --- /dev/null +++ b/docs/logo.svg @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/modules/activemq.md b/docs/modules/activemq.md new file mode 100644 index 00000000000..7959c47576a --- /dev/null +++ b/docs/modules/activemq.md @@ -0,0 +1,57 @@ +# ActiveMQ + +Testcontainers module for [ActiveMQ](https://hub.docker.com/r/apache/activemq) and +[Artemis](https://hub.docker.com/r/apache/artemis). + +## ActiveMQContainer's usage examples + +You can start an ActiveMQ Classic container instance from any Java application by using: + + +[Default ActiveMQ container](../../modules/activemq/src/test/java/org/testcontainers/activemq/ActiveMQContainerTest.java) inside_block:container + + +With custom credentials: + + +[Setting custom credentials](../../modules/activemq/src/test/java/org/testcontainers/activemq/ActiveMQContainerTest.java) inside_block:settingCredentials + + +## ArtemisContainer's usage examples + +You can start an ActiveMQ Artemis container instance from any Java application by using: + + +[Default Artemis container](../../modules/activemq/src/test/java/org/testcontainers/activemq/ArtemisContainerTest.java) inside_block:container + + +With custom credentials: + + +[Setting custom credentials](../../modules/activemq/src/test/java/org/testcontainers/activemq/ArtemisContainerTest.java) inside_block:settingCredentials + + +With anonymous login: + + +[Allow anonymous login](../../modules/activemq/src/test/java/org/testcontainers/activemq/ArtemisContainerTest.java) inside_block:enableAnonymousLogin + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-activemq:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-activemq + {{latest_version}} + test + + ``` diff --git a/docs/modules/azure.md b/docs/modules/azure.md new file mode 100644 index 00000000000..461fcc9f679 --- /dev/null +++ b/docs/modules/azure.md @@ -0,0 +1,193 @@ +# Azure Module + +!!! note +This module is INCUBATING. While it is ready for use and operational in the current version of Testcontainers, it is possible that it may receive breaking changes in the future. See [our contributing guidelines](/contributing/#incubating-modules) for more information on our incubating modules policy. + +Testcontainers module for the Microsoft Azure's [SDK](https://github.com/Azure/azure-sdk-for-java). + +Currently, the module supports `Azurite`, `Azure Event Hubs`, `Azure Service Bus` and `CosmosDB` emulators. In order to use them, you should use the following classes: + +Class | Container Image +-|- +AzuriteContainer | [mcr.microsoft.com/azure-storage/azurite](https://github.com/microsoft/containerregistry) +EventHubsEmulatorContainer | [mcr.microsoft.com/azure-messaging/eventhubs-emulator](https://github.com/microsoft/containerregistry) +ServiceBusEmulatorContainer | [mcr.microsoft.com/azure-messaging/servicebus-emulator](https://github.com/microsoft/containerregistry) +CosmosDBEmulatorContainer | [mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator](https://github.com/microsoft/containerregistry) + +## Usage example + +### Azurite Storage Emulator + +Start Azurite Emulator during a test: + + +[Starting an Azurite container](../../modules/azure/src/test/java/org/testcontainers/azure/AzuriteContainerTest.java) inside_block:emulatorContainer + + +!!! note + SSL configuration is possible using the `withSsl(MountableFile, String)` and `withSsl(MountableFile, MountableFile)` methods. + +Newer Azure Storage SDK versions can send API versions that Azurite does not support. Use `withCommandOptions(...)` to append extra Azurite flags such as `--skipApiVersionCheck`. `AzuriteContainer` rebuilds its process command in `configure()`, so `.withCommand(...)` cannot be used for extra flags. + + +[Pass extra Azurite command options](../../modules/azure/src/test/java/org/testcontainers/azure/AzuriteContainerCommandTest.java) inside_block:commandOptions + + +If the tested application needs to use more than one set of credentials, the container can be configured to use custom credentials. +Please see some examples below. + + +[Starting an Azurite Blob container with one account and two keys](../../modules/azure/src/test/java/org/testcontainers/azure/AzuriteContainerTest.java) inside_block:withTwoAccountKeys + + + +[Starting an Azurite Blob container with more accounts and keys](../../modules/azure/src/test/java/org/testcontainers/azure/AzuriteContainerTest.java) inside_block:withMoreAccounts + + +#### Using with Blob + +Build Azure Blob client: + + +[Build Azure Blob Service client](../../modules/azure/src/test/java/org/testcontainers/azure/AzuriteContainerTest.java) inside_block:createBlobClient + + +In case the application needs to use custom credentials, we can obtain them with a different method: + + +[Obtain connection string with non-default credentials](../../modules/azure/src/test/java/org/testcontainers/azure/AzuriteContainerTest.java) inside_block:useNonDefaultCredentials + + +#### Using with Queue + +Build Azure Queue client: + + +[Build Azure Queue Service client](../../modules/azure/src/test/java/org/testcontainers/azure/AzuriteContainerTest.java) inside_block:createQueueClient + + +!!! note + We can use custom credentials the same way as defined in the Blob section. + +#### Using with Table + +Build Azure Table client: + + +[Build Azure Table Service client](../../modules/azure/src/test/java/org/testcontainers/azure/AzuriteContainerTest.java) inside_block:createTableClient + + +!!! note + We can use custom credentials the same way as defined in the Blob section. + +### Azure Event Hubs Emulator + + +[Configuring the Azure Event Hubs Emulator container](../../modules/azure/src/test/resources/eventhubs_config.json) + + +Start Azure Event Hubs Emulator during a test: + + +[Setting up a network](../../modules/azure/src/test/java/org/testcontainers/azure/EventHubsEmulatorContainerTest.java) inside_block:network + + + +[Starting an Azurite container as dependency](../../modules/azure/src/test/java/org/testcontainers/azure/EventHubsEmulatorContainerTest.java) inside_block:azuriteContainer + + + +[Starting an Azure Event Hubs Emulator container](../../modules/azure/src/test/java/org/testcontainers/azure/EventHubsEmulatorContainerTest.java) inside_block:emulatorContainer + + +#### Using Azure Event Hubs clients + +Configure the consumer and the producer clients: + + +[Configuring the clients](../../modules/azure/src/test/java/org/testcontainers/azure/EventHubsEmulatorContainerTest.java) inside_block:createProducerAndConsumer + + +### Azure Service Bus Emulator + + +[Configuring the Azure Service Bus Emulator container](../../modules/azure/src/test/resources/service-bus-config.json) + + +Start Azure Service Bus Emulator during a test: + + +[Setting up a network](../../modules/azure/src/test/java/org/testcontainers/azure/ServiceBusEmulatorContainerTest.java) inside_block:network + + + +[Starting a SQL Server container as dependency](../../modules/azure/src/test/java/org/testcontainers/azure/ServiceBusEmulatorContainerTest.java) inside_block:sqlContainer + + + +[Starting a Service Bus Emulator container](../../modules/azure/src/test/java/org/testcontainers/azure/ServiceBusEmulatorContainerTest.java) inside_block:emulatorContainer + + +#### Using Azure Service Bus clients + +Configure the sender and the processor clients: + + +[Configuring the sender client](../../modules/azure/src/test/java/org/testcontainers/azure/ServiceBusEmulatorContainerTest.java) inside_block:senderClient + + + +[Configuring the processor client](../../modules/azure/src/test/java/org/testcontainers/azure/ServiceBusEmulatorContainerTest.java) inside_block:processorClient + + +### CosmosDB + +Start Azure CosmosDB Emulator during a test: + + +[Starting an Azure CosmosDB Emulator container](../../modules/azure/src/test/java/org/testcontainers/containers/CosmosDBEmulatorContainerTest.java) inside_block:emulatorContainer + + +Prepare KeyStore to use for SSL. + + +[Building KeyStore from certificate within container](../../modules/azure/src/test/java/org/testcontainers/containers/CosmosDBEmulatorContainerTest.java) inside_block:buildAndSaveNewKeyStore + + +Set system trust-store parameters to use already built KeyStore: + + +[Set system trust-store parameters](../../modules/azure/src/test/java/org/testcontainers/containers/CosmosDBEmulatorContainerTest.java) inside_block:setSystemTrustStoreParameters + + +Build Azure CosmosDB client: + + +[Build Azure CosmosDB client](../../modules/azure/src/test/java/org/testcontainers/containers/CosmosDBEmulatorContainerTest.java) inside_block:buildClient + + +Test against the Emulator: + + +[Testing against Azure CosmosDB Emulator container](../../modules/azure/src/test/java/org/testcontainers/containers/CosmosDBEmulatorContainerTest.java) inside_block:testWithClientAgainstEmulatorContainer + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-azure:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-azure + {{latest_version}} + test + + ``` + diff --git a/docs/modules/chromadb.md b/docs/modules/chromadb.md new file mode 100644 index 00000000000..bb50dfe3c02 --- /dev/null +++ b/docs/modules/chromadb.md @@ -0,0 +1,30 @@ +# ChromaDB + +Testcontainers module for [ChromaDB](https://registry.hub.docker.com/r/chromadb/chroma) + +## ChromaDB's usage examples + +You can start a ChromaDB container instance from any Java application by using: + + +[Default ChromaDB container](../../modules/chromadb/src/test/java/org/testcontainers/chromadb/ChromaDBContainerTest.java) inside_block:container + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" +```groovy +testImplementation "org.testcontainers:testcontainers-chromadb:{{latest_version}}" +``` + +=== "Maven" +```xml + +org.testcontainers +testcontainers-chromadb +{{latest_version}} +test + +``` diff --git a/docs/modules/consul.md b/docs/modules/consul.md new file mode 100644 index 00000000000..bcdf7146da3 --- /dev/null +++ b/docs/modules/consul.md @@ -0,0 +1,35 @@ +# Hashicorp Consul Module + +Testcontainers module for [Consul](https://github.com/hashicorp/consul). Consul is a tool for managing key value properties. More information on Consul [here](https://www.consul.io/). + +## Usage example + + +[Running Consul in your Junit tests](../../modules/consul/src/test/java/org/testcontainers/consul/ConsulContainerTest.java) + + +## Why Consul in Junit tests? + +With the increasing popularity of Consul and config externalization, applications are now needing to source properties from Consul. +This can prove challenging in the development phase without a running Consul instance readily on hand. This library +aims to solve your apps integration testing with Consul. You can also use it to +test how your application behaves with Consul by writing different test scenarios in Junit. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-consul:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-consul + {{latest_version}} + test + + ``` diff --git a/docs/modules/databases/cassandra.md b/docs/modules/databases/cassandra.md new file mode 100644 index 00000000000..9cc5e12499b --- /dev/null +++ b/docs/modules/databases/cassandra.md @@ -0,0 +1,53 @@ +# Cassandra Module + +## Usage example + +This example connects to the Cassandra cluster: + +1. Define a container: + + [Container definition](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java) inside_block:container-definition + + +2. Build a `CqlSession`: + + [Building CqlSession](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java) inside_block:cql-session + + +3. Define a container with custom `cassandra.yaml` located in a directory `cassandra-auth-required-configuration`: + + + [Running init script with required authentication](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java) inside_block:init-with-auth + + +## Using secure connection (TLS) + +If you override the default `cassandra.yaml` with a version setting the property `client_encryption_options.optional` +to `false`, you have to provide a valid client certificate and key (PEM format) when you initialize your container: + + +[SSL setup](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java) inside_block:with-ssl-config + + +!!! hint + To generate the client certificate and key, please refer to + [this documentation](https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureSSLCertificates.html). + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-cassandra:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-cassandra + {{latest_version}} + test + + ``` diff --git a/docs/modules/databases/clickhouse.md b/docs/modules/databases/clickhouse.md new file mode 100644 index 00000000000..4e49c69c276 --- /dev/null +++ b/docs/modules/databases/clickhouse.md @@ -0,0 +1,40 @@ +# Clickhouse Module + +Testcontainers module for [ClickHouse](https://hub.docker.com/r/clickhouse/clickhouse-server) + +## Usage example + +You can start a ClickHouse container instance from any Java application by using: + + +[Container definition](../../../modules/clickhouse/src/test/java/org/testcontainers/clickhouse/ClickHouseContainerTest.java) inside_block:container + + +### Testcontainers JDBC URL + +`jdbc:tc:clickhouse:18.10.3:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-clickhouse:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-clickhouse + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency. + diff --git a/docs/modules/databases/cockroachdb.md b/docs/modules/databases/cockroachdb.md new file mode 100644 index 00000000000..add1b9cd1aa --- /dev/null +++ b/docs/modules/databases/cockroachdb.md @@ -0,0 +1,41 @@ +# CockroachDB Module + +Testcontainers module for [CockroachDB](https://hub.docker.com/r/cockroachdb/cockroach) + +## Usage example + +You can start a CockroachDB container instance from any Java application by using: + + +[Container definition](../../../modules/cockroachdb/src/test/java/org/testcontainers/cockroachdb/CockroachContainerTest.java) inside_block:container + + +See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. + +### Testcontainers JDBC URL + +`jdbc:tc:cockroach:v21.2.3:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-cockroachdb:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-cockroachdb + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency. diff --git a/docs/modules/databases/couchbase.md b/docs/modules/databases/couchbase.md new file mode 100644 index 00000000000..997347611e2 --- /dev/null +++ b/docs/modules/databases/couchbase.md @@ -0,0 +1,42 @@ +# Couchbase Module + + + +Testcontainers module for Couchbase. [Couchbase](https://www.couchbase.com/) is a document oriented NoSQL database. + +## Usage example + +Running Couchbase as a stand-in in a test: + +1. Define a bucket: + + [Bucket Definition](../../../modules/couchbase/src/test/java/org/testcontainers/couchbase/CouchbaseContainerTest.java) inside_block:bucket_definition + + +2. define a container: + + [Container definition](../../../modules/couchbase/src/test/java/org/testcontainers/couchbase/CouchbaseContainerTest.java) inside_block:container_definition + + +3. create an cluster: + + [Cluster creation](../../../modules/couchbase/src/test/java/org/testcontainers/couchbase/CouchbaseContainerTest.java) inside_block:cluster_creation + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-couchbase:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-couchbase + {{latest_version}} + test + + ``` diff --git a/docs/modules/databases/cratedb.md b/docs/modules/databases/cratedb.md new file mode 100644 index 00000000000..25fd1642a9d --- /dev/null +++ b/docs/modules/databases/cratedb.md @@ -0,0 +1,41 @@ +# CrateDB Module + +Testcontainers module for [CrateDB](https://hub.docker.com/_/crate) + +## Usage example + +You can start a CrateDB container instance from any Java application by using: + + +[Container definition](../../../modules/cratedb/src/test/java/org/testcontainers/junit/cratedb/SimpleCrateDBTest.java) inside_block:container + + +See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. + +### Testcontainers JDBC URL + +`jdbc:tc:cratedb:5.2.3:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-cratedb:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-cratedb + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency. + diff --git a/docs/modules/databases/databend.md b/docs/modules/databases/databend.md new file mode 100644 index 00000000000..510dffb2f3e --- /dev/null +++ b/docs/modules/databases/databend.md @@ -0,0 +1,40 @@ +# Databend Module + +Testcontainers module for [Databend](https://hub.docker.com/r/datafuselabs/databend) + +## Usage example + +You can start a Databend container instance from any Java application by using: + + +[Container definition](../../../modules/databend/src/test/java/org/testcontainers/databend/DatabendContainerTest.java) inside_block:container + + +### Testcontainers JDBC URL + +`jdbc:tc:databend:v1.2.615:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-databend:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-databend + {{latest_version}} + test + + ``` + +!!! hint +Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency. + diff --git a/docs/modules/databases/db2.md b/docs/modules/databases/db2.md new file mode 100644 index 00000000000..ce05f6ae308 --- /dev/null +++ b/docs/modules/databases/db2.md @@ -0,0 +1,43 @@ +# DB2 Module + +Testcontainers module for [DB2](https://www.ibm.com/docs/en/db2/11.5.x?topic=deployments-db2-community-edition-docker) + +## Usage example + +You can start a DB2 container instance from any Java application by using: + + +[Container definition](../../../modules/db2/src/test/java/org/testcontainers/db2/Db2ContainerTest.java) inside_block:container + + +!!! warning "EULA Acceptance" + Due to licencing restrictions you are required to accept an EULA for this container image. To indicate that you accept the DB2 image EULA, call the `acceptLicense()` method, or place a file at the root of the classpath named `container-license-acceptance.txt`, e.g. at `src/test/resources/container-license-acceptance.txt`. This file should contain the line: `ibmcom/db2:11.5.0.0a` (or, if you are overriding the docker image name/tag, update accordingly). + +See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. + +### Testcontainers JDBC URL + +`jdbc:tc:db2:11.5.0.0a:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-db2:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-db2 + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency. diff --git a/docs/modules/databases/index.md b/docs/modules/databases/index.md new file mode 100644 index 00000000000..6685ade55c8 --- /dev/null +++ b/docs/modules/databases/index.md @@ -0,0 +1,13 @@ +# Database containers + +## Overview + +You might want to use Testcontainers' database support: + + * **Instead of H2 database for DAO unit tests that depend on database features that H2 doesn't emulate.** Testcontainers is not as performant as H2, but does give you the benefit of 100% database compatibility (since it runs a real DB inside of a container). + * **Instead of a database running on the local machine or in a VM** for DAO unit tests or end-to-end integration tests that need a database to be present. In this context, the benefit of Testcontainers is that the database always starts in a known state, without any contamination between test runs or on developers' local machines. + +!!! note + Of course, it's still important to have as few tests that hit the database as possible, and make good use of mocks for components higher up the stack. + +See [JDBC](./jdbc.md) and [R2DBC](./r2dbc.md) for information on how to use Testcontainers with SQL-like databases. \ No newline at end of file diff --git a/docs/modules/databases/influxdb.md b/docs/modules/databases/influxdb.md new file mode 100644 index 00000000000..9644a5eb92b --- /dev/null +++ b/docs/modules/databases/influxdb.md @@ -0,0 +1,109 @@ +# InfluxDB Module + +Testcontainers module for InfluxData [InfluxDB](https://www.influxdata.com/products/influxdb/). + +## Important note + +There are breaking changes in InfluxDB 2.x. +For more information refer to the main [documentation](https://docs.influxdata.com/influxdb/v2.0/upgrade/v1-to-v2/). +You can find more information about the official InfluxDB image on [Docker Hub](https://hub.docker.com/_/influxdb). + +## InfluxDB 2.x usage example + +Running a `InfluxDBContainer` as a stand-in for InfluxDB in a test: + + +[Create an InfluxDB container](../../../modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerTest.java) inside_block:constructorWithDefaultVariables + + + +The InfluxDB instance will be setup with the following data:
+ +| Property | Default Value | +|--------------|:-------------:| +| username | test-user | +| password | test-password | +| organization | test-org | +| bucket | test-bucket | +| retention | 0 (infinite) | +| adminToken | - | + +For more details about the InfluxDB setup, please visit the official [InfluxDB documentation](https://docs.influxdata.com/influxdb/v2.0/upgrade/v1-to-v2/docker/#influxdb-2x-initialization-credentials). + +It is possible to overwrite the default property values. Create a container with InfluxDB admin token: + +[Create an InfluxDB container with admin token](../../../modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerTest.java) inside_block:constructorWithAdminToken + + +Or create a container with custom username, password, bucket, organization, and retention time: + +[Create an InfluxDB container with custom settings](../../../modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerTest.java) inside_block:constructorWithCustomVariables + + +The following code snippet shows how you can create an InfluxDB Java client: + + +[Create an InfluxDB Java client](../../../modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerTest.java) inside_block:createInfluxDBClient + + +!!! hint + You can find the latest documentation about the InfluxDB 2.x Java client [here](https://github.com/influxdata/influxdb-client-java). + +## InfluxDB 1.x usage example + +Running a `InfluxDBContainer` as a stand-in for InfluxDB in a test with default env variables: + + +[Create an InfluxDB container](../../../modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerV1Test.java) inside_block:constructorWithDefaultVariables + + +The InfluxDB instance will be setup with the following data:
+ +| Property | Default Value | +|---------------|:-------------:| +| username | test-user | +| password | test-password | +| authEnabled | true | +| admin | admin | +| adminPassword | password | +| database | - | + +It is possible to overwrite the default values. +For instance, creating an InfluxDB container with a custom username, password, and database name: + +[Create an InfluxDB container with custom settings](../../../modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerV1Test.java) inside_block:constructorWithUserPassword + + +In the following example you will find a snippet to create an InfluxDB client using the official Java client: + + +[Create an InfluxDB Java client](../../../modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerV1Test.java) inside_block:createInfluxDBClient + + +!!! hint + You can find the latest documentation about the InfluxDB 1.x Java client [here](https://github.com/influxdata/influxdb-java). + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + +```groovy +testImplementation "org.testcontainers:testcontainers-influxdb:{{latest_version}}" +``` + +=== "Maven" + +```xml + + + org.testcontainers + testcontainers-influxdb + {{latest_version}} + test + +``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency. diff --git a/docs/modules/databases/jdbc.md b/docs/modules/databases/jdbc.md new file mode 100644 index 00000000000..72539aef44e --- /dev/null +++ b/docs/modules/databases/jdbc.md @@ -0,0 +1,176 @@ +# JDBC support + +You can obtain a temporary database in one of two ways: + + * **Using a specially modified JDBC URL**: after making a very simple modification to your system's JDBC URL string, Testcontainers will provide a disposable stand-in database that can be used without requiring modification to your application code. + * **JUnit @Rule/@ClassRule**: this mode starts a database inside a container before your tests and tears it down afterwards. + +## Database containers launched via JDBC URL scheme + +As long as you have Testcontainers and the appropriate JDBC driver on your classpath, you can simply modify regular JDBC connection URLs to get a fresh containerized instance of the database each time your application starts up. + +_N.B:_ + +* _TC needs to be on your application's classpath at runtime for this to work_ +* _For Spring Boot (Before version `2.3.0`) you need to specify the driver manually `spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver`_ + +**Original URL**: `jdbc:mysql://localhost:3306/databasename` + +Insert `tc:` after `jdbc:` as follows. Note that the hostname, port and database name will be ignored; you can leave these as-is or set them to any value. + +!!! note + We will use `///` (host-less URIs) from now on to emphasis the unimportance of the `host:port` pair. + From Testcontainers' perspective, `jdbc:mysql:8.0.36://localhost:3306/databasename` and `jdbc:mysql:8.0.36:///databasename` is the same URI. + +!!! warning + If you're using the JDBC URL support, there is no need to instantiate an instance of the container - Testcontainers will do it automagically. + +### JDBC URL examples + +#### Using ClickHouse + +`jdbc:tc:clickhouse:18.10.3:///databasename` + +#### Using CockroachDB + +`jdbc:tc:cockroach:v21.2.3:///databasename` + +#### Using CrateDB + +`jdbc:tc:cratedb:5.2.3:///databasename` + +#### Using DB2 + +`jdbc:tc:db2:11.5.0.0a:///databasename` + +#### Using MariaDB + +`jdbc:tc:mariadb:10.3.39:///databasename` + +#### Using MySQL + +`jdbc:tc:mysql:8.0.36:///databasename` + +#### Using MSSQL Server + +`jdbc:tc:sqlserver:2017-CU12:///databasename` + +#### Using OceanBase + +`jdbc:tc:oceanbasece:4.2.1-lts:///databasename` + +#### Using Oracle + +`jdbc:tc:oracle:21-slim-faststart:///databasename` + +#### Using PostGIS + +`jdbc:tc:postgis:9.6-2.5:///databasename` + +#### Using PostgreSQL + +`jdbc:tc:postgresql:9.6.8:///databasename` + +#### Using QuestDB + +`jdbc:tc:questdb:6.5.3:///databasename` + +#### Using TimescaleDB + +`jdbc:tc:timescaledb:2.1.0-pg13:///databasename` + +#### Using PGVector + +`jdbc:tc:pgvector:pg16:///databasename` + +#### Using TiDB + +`jdbc:tc:tidb:v6.1.0:///databasename` + +#### Using Timeplus + +`jdbc:tc:timeplus:2.3.21:///databasename` + +#### Using Trino + +`jdbc:tc:trino:352://localhost/memory/default` + +#### Using YugabyteDB + +`jdbc:tc:yugabyte:2.14.4.0-b26:///databasename` + + +### Using a classpath init script + +Testcontainers can run an init script after the database container is started, but before your code is given a connection to it. The script must be on the classpath, and is referenced as follows: + +`jdbc:tc:mysql:8.0.36:///databasename?TC_INITSCRIPT=somepath/init_mysql.sql` + +This is useful if you have a fixed script for setting up database schema, etc. + +### Using an init script from a file + +If the init script path is prefixed `file:`, it will be loaded from a file (relative to the working directory, which will usually be the project root). + +`jdbc:tc:mysql:8.0.36:///databasename?TC_INITSCRIPT=file:src/main/resources/init_mysql.sql` + +### Using an init function + +Instead of running a fixed script for DB setup, it may be useful to call a Java function that you define. This is intended to allow you to trigger database schema migration tools. To do this, add TC_INITFUNCTION to the URL as follows, passing a full path to the class name and method: + + `jdbc:tc:mysql:8.0.36:///databasename?TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction` + +The init function must be a public static method which takes a `java.sql.Connection` as its only parameter, e.g. +```java +public class JDBCDriverTest { + public static void sampleInitFunction(Connection connection) throws SQLException { + // e.g. run schema setup or Flyway/liquibase/etc DB migrations here... + } + ... +``` + +### Running container in daemon mode + +By default database container is being stopped as soon as last connection is closed. There are cases when you might need to start container and keep it running till you stop it explicitly or JVM is shutdown. To do this, add `TC_DAEMON` parameter to the URL as follows: + + `jdbc:tc:mysql:8.0.36:///databasename?TC_DAEMON=true` + +With this parameter database container will keep running even when there's no open connections. + + +### Running container with tmpfs options + +Container can have `tmpfs` mounts for storing data in host memory. This is useful if you want to speed up your database tests. Be aware that the data will be lost when the container stops. + +To pass this option to the container, add `TC_TMPFS` parameter to the URL as follows: + + `jdbc:tc:postgresql:9.6.8:///databasename?TC_TMPFS=/testtmpfs:rw` + +If you need more than one option, separate them by comma (e.g. `TC_TMPFS=key:value,key1:value1&other_parameters=foo`). + +For more information about `tmpfs` mount, see [the official Docker documentation](https://docs.docker.com/storage/tmpfs/). + +## Database container objects + +In case you can't use the URL support, or need to fine-tune the container, you can instantiate it yourself. + +Add a @Rule or @ClassRule to your test class, e.g.: + +```java +public class SimpleMySQLTest { + @Rule + public MySQLContainer mysql = new MySQLContainer(); +``` + +Now, in your test code (or a suitable setup method), you can obtain details necessary to connect to this database: + + * `mysql.getJdbcUrl()` provides a JDBC URL your code can connect to + * `mysql.getUsername()` provides the username your code should pass to the driver + * `mysql.getPassword()` provides the password your code should pass to the driver + +Note that if you use `@Rule`, you will be given an isolated container for each test method. If you use `@ClassRule`, you will get on isolated container for all the methods in the test class. + +Examples/Tests: + + * [MySQL](https://github.com/testcontainers/testcontainers-java/blob/main/modules/mysql/src/test/java/org/testcontainers/junit/mysql/SimpleMySQLTest.java) + * [PostgreSQL](https://github.com/testcontainers/testcontainers-java/blob/main/modules/postgresql/src/test/java/org/testcontainers/junit/postgresql/SimplePostgreSQLTest.java) diff --git a/docs/modules/databases/mariadb.md b/docs/modules/databases/mariadb.md new file mode 100644 index 00000000000..d90f9381cb5 --- /dev/null +++ b/docs/modules/databases/mariadb.md @@ -0,0 +1,47 @@ +# MariaDB Module + +Testcontainers module for [MariaDB](https://hub.docker.com/_/mariadb) + +## Usage example + +You can start a MySQL container instance from any Java application by using: + + +[Container definition](../../../modules/mariadb/src/test/java/org/testcontainers/mariadb/MariaDBContainerTest.java) inside_block:container + + +See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. + +### Testcontainers JDBC URL + +`jdbc:tc:mariadb:10.3.39:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +## MariaDB `root` user password + +If no custom password is specified, the container will use the default user password `test` for the `root` user as well. +When you specify a custom password for the database user, +this will also act as the password of the MariaDB `root` user automatically. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-mariadb:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-mariadb + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency. + diff --git a/docs/modules/databases/mongodb.md b/docs/modules/databases/mongodb.md new file mode 100644 index 00000000000..b861d83bb2c --- /dev/null +++ b/docs/modules/databases/mongodb.md @@ -0,0 +1,96 @@ +# MongoDB Module + +The MongoDB module provides two Testcontainers for MongoDB unit testing: + +* [MongoDBContainer](#mongodbcontainer) - the core MongoDB database +* [MongoDBAtlasLocalContainer](#mongodbatlaslocalcontainer) - the core MongoDB database combined with MongoDB Atlas Search + Atlas Vector Search + +## MongoDBContainer + +### Usage example + +The following example shows how to create a MongoDBContainer: + + +[Creating a MongoDB container](../../../modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBContainerTest.java) inside_block:creatingMongoDBContainer + + +And how to start it: + + +[Starting a MongoDB container](../../../modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBContainerTest.java) inside_block:startingMongoDBContainer + + +!!! note + To construct a multi-node MongoDB cluster, consider the [mongodb-replica-set project](https://github.com/silaev/mongodb-replica-set/) + +#### Motivation +Implement a reusable, cross-platform, simple to install solution that doesn't depend on +fixed ports to test MongoDB transactions. + +#### General info +MongoDB starting from version 4 supports multi-document transactions only for a replica set. +For instance, to initialize a single node replica set on fixed ports via Docker, one has to do the following: + +* Run a MongoDB container of version 4 and up specifying --replSet command +* Initialize a single replica set via executing a proper command +* Wait for the initialization to complete +* Provide a special url for a user to employ with a MongoDB driver without specifying replicaSet + +As we can see, there is a lot of operations to execute and we even haven't touched a non-fixed port approach. +That's where the MongoDBContainer might come in handy. + +## MongoDBAtlasLocalContainer + +### Usage example + +The following example shows how to create a MongoDBAtlasLocalContainer: + + +[Creating a MongoDB Atlas Local Container](../../../modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainerTest.java) inside_block:creatingAtlasLocalContainer + + +And how to start it: + + +[Start the Container](../../../modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainerTest.java) inside_block:startingAtlasLocalContainer + + +The connection string provided by the MongoDBAtlasLocalContainer's getConnectionString() method includes the dynamically allocated port: + + +[Get the Connection String](../../../modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainerTest.java) inside_block:getConnectionStringAtlasLocalContainer + + +e.g. `mongodb://localhost:12345/?directConnection=true` + +### References +MongoDB Atlas Local combines the MongoDB database engine with MongoT, a sidecar process for advanced searching capabilities built by MongoDB and powered by [Apache Lucene](https://lucene.apache.org/). + +The container (mongodb/mongodb-atlas-local) documentation can be found [here](https://www.mongodb.com/docs/atlas/cli/current/atlas-cli-deploy-docker/). + +General information about Atlas Search can be found [here](https://www.mongodb.com/docs/atlas/atlas-search/). + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-mongodb:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-mongodb + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency + +#### Copyright +Copyright (c) 2019 Konstantin Silaev diff --git a/docs/modules/databases/mssqlserver.md b/docs/modules/databases/mssqlserver.md new file mode 100644 index 00000000000..5c9d7b0a9fb --- /dev/null +++ b/docs/modules/databases/mssqlserver.md @@ -0,0 +1,56 @@ +# MS SQL Server Module + +Testcontainers module for [MS SQL Server](https://mcr.microsoft.com/en-us/artifact/mar/mssql/server/) + +## Usage example + +You can start a MS SQL Server container instance from any Java application by using: + + +[Container definition](../../../modules/mssqlserver/src/test/java/org/testcontainers/mssqlserver/MSSQLServerContainerTest.java) inside_block:container + + +!!! warning "EULA Acceptance" + Due to licencing restrictions you are required to accept an EULA for this container image. To indicate that you accept the MS SQL Server image EULA, call the `acceptLicense()` method, or place a file at the root of the classpath named `container-license-acceptance.txt`, e.g. at `src/test/resources/container-license-acceptance.txt`. This file should contain the line: `mcr.microsoft.com/mssql/server:2017-CU12` (or, if you are overriding the docker image name/tag, update accordingly). + + Please see the [`microsoft-mssql-server` image documentation](https://hub.docker.com/_/microsoft-mssql-server#environment-variables) for a link to the EULA document. + +See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. + +### Testcontainers JDBC URL + +`jdbc:tc:sqlserver:2017-CU12:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-mssqlserver:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-mssqlserver + {{latest_version}} + test + + ``` + + +!!! hint + Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency. + +## License + +See [LICENSE](https://raw.githubusercontent.com/testcontainers/testcontainers-java/main/modules/mssqlserver/LICENSE). + +## Copyright + +Copyright (c) 2017 - 2019 G DATA Software AG and other authors. + +See [AUTHORS](https://raw.githubusercontent.com/testcontainers/testcontainers-java/main/modules/mssqlserver/AUTHORS) for contributors. diff --git a/docs/modules/databases/mysql.md b/docs/modules/databases/mysql.md new file mode 100644 index 00000000000..1747989766f --- /dev/null +++ b/docs/modules/databases/mysql.md @@ -0,0 +1,56 @@ +# MySQL Module + +Testcontainers module for [MySQL](https://hub.docker.com/_/mysql) + +## Usage example + +You can start a MySQL container instance from any Java application by using: + + +[Container definition](../../../modules/mysql/src/test/java/org/testcontainers/mysql/MySQLContainerTest.java) inside_block:container + + +See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. + +### Testcontainers JDBC URL + +`jdbc:tc:mysql:8.0.36:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +## Overriding MySQL my.cnf settings + +For MySQL databases, it is possible to override configuration settings using resources on the classpath. Assuming `somepath/mysql_conf_override` +is a directory on the classpath containing .cnf files, the following URL can be used: + + `jdbc:tc:mysql:8.0.36://hostname/databasename?TC_MY_CNF=somepath/mysql_conf_override` + +Any .cnf files in this classpath directory will be mapped into the database container's /etc/mysql/conf.d directory, +and will be able to override server settings when the container starts. + +## MySQL `root` user password + +If no custom password is specified, the container will use the default user password `test` for the `root` user as well. +When you specify a custom password for the database user, +this will also act as the password of the MySQL `root` user automatically. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-mysql:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-mysql + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency. diff --git a/docs/modules/databases/neo4j.md b/docs/modules/databases/neo4j.md new file mode 100644 index 00000000000..c7b06e99fe7 --- /dev/null +++ b/docs/modules/databases/neo4j.md @@ -0,0 +1,143 @@ +# Neo4j Module + +This module helps to run [Neo4j](https://neo4j.com/download/) using Testcontainers. + +Note that it's based on the [official Docker image](https://hub.docker.com/_/neo4j/) provided by Neo4j, Inc. + +Even though the latest LTS version of Neo4j 4.4 is used in the examples of this documentation, +the Testcontainers integration supports also newer 5.x images of Neo4j. + +## Usage example + +You can start a Neo4j container instance from any Java application by using: + + +[Neo4j container](../../../modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java) inside_block:container + + +## Additional features + +### Custom password + +A custom password can be provided: + + +[Custom password](../../../modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java) inside_block:withAdminPassword + + +### Disable authentication + +Authentication can be disabled: + + +[Disable authentication](../../../modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java) inside_block:withoutAuthentication + + +### Random password + +A random (`UUID`-random based) password can be set: + + +[Random password](../../../modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java) inside_block:withRandomPassword + + +### Neo4j-Configuration + +Neo4j's Docker image needs Neo4j configuration options in a dedicated format. +The container takes care of that, and you can configure the database with standard options like the following: + + +[Neo4j configuration](../../../modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java) inside_block:neo4jConfiguration + + +### Add custom plugins + +Custom plugins, like APOC, can be copied over to the container from any classpath or host resource like this: + + +[Plugin jar](../../../modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java) inside_block:registerPluginsJar + + +Whole directories work as well: + + +[Plugin folder](../../../modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java) inside_block:registerPluginsPath + + +### Add Neo4j Docker Labs plugins + +Add any Neo4j Labs plugin from the [Neo4j 4.4 Docker Labs plugin list](https://neo4j.com/docs/operations-manual/4.4/docker/operations/#docker-neo4jlabs-plugins) +or [Neo4j 5 plugin list](https://neo4j.com/docs/operations-manual/5/configuration/plugins/). + +!!! note + The methods `withLabsPlugins(Neo4jLabsPlugin...)` and `withLabsPlugins(String... plugins)` are deprecated. + Please the method `withPlugins(String... plugins)`. + + +[Configure Neo4j Labs Plugins](../../../modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java) inside_block:configureLabsPlugins + + + +### Start the container with a predefined database + +If you have an existing database (`graph.db`) you want to work with, copy it over to the container like this: + + +[Copy database](../../../modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java) inside_block:copyDatabase + + +!!! note + The `withDatabase` method will only work with Neo4j 3.5 and throw an exception if used in combination with a newer version. + +## Choose your Neo4j license + +If you need the Neo4j enterprise license, you can declare your Neo4j container like this: + + +[Enterprise edition](../../../modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java) inside_block:enterpriseEdition + + +This creates a Testcontainers based on the Docker image build with the Enterprise version of Neo4j 4.4. +The call to `withEnterpriseEdition` adds the required environment variable that you accepted the terms and condition of the enterprise version. +You accept those by adding a file named `container-license-acceptance.txt` to the root of your classpath containing the text `neo4j:4.4-enterprise` in one line. + +If you are planning to run a newer Neo4j 5.x enterprise edition image, you have to manually define the proper enterprise image (e.g. `neo4j:5-enterprise`) +and set the environment variable `NEO4J_ACCEPT_LICENSE_AGREEMENT` by adding `.withEnv("NEO4J_ACCEPT_LICENSE_AGREEMENT", "yes")` to your container definition. + +You'll find more information about licensing Neo4j here: [About Neo4j Licenses](https://neo4j.com/licensing/). + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-neo4j:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-neo4j + {{latest_version}} + test + + ``` + +!!! hint + Add the Neo4j Java driver if you plan to access the Testcontainers via Bolt: + + === "Gradle" + ```groovy + compile "org.neo4j.driver:neo4j-java-driver:4.4.13" + ``` + + === "Maven" + ```xml + + org.neo4j.driver + neo4j-java-driver + 4.4.13 + + ``` diff --git a/docs/modules/databases/oceanbase.md b/docs/modules/databases/oceanbase.md new file mode 100644 index 00000000000..c315a871058 --- /dev/null +++ b/docs/modules/databases/oceanbase.md @@ -0,0 +1,41 @@ +# OceanBase Module + +Testcontainers module for [OceanBase](https://hub.docker.com/r/oceanbase/oceanbase-ce) + +## Usage example + +You can start an OceanBase container instance from any Java application by using: + + +[Container definition](../../../modules/oceanbase/src/test/java/org/testcontainers/oceanbase/SimpleOceanBaseCETest.java) inside_block:container + + +See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. + +### Testcontainers JDBC URL + +`jdbc:tc:oceanbasece:4.2.1-lts:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-oceanbase:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-oceanbase + {{latest_version}} + test + + ``` + +!!! hint +Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency. diff --git a/docs/modules/databases/oraclefree.md b/docs/modules/databases/oraclefree.md new file mode 100644 index 00000000000..75f3d677ffc --- /dev/null +++ b/docs/modules/databases/oraclefree.md @@ -0,0 +1,42 @@ +# Oracle Database Free Module + +Testcontainers module for [Oracle Free](https://hub.docker.com/r/gvenzl/oracle-free) + +## Usage example + +You can start an Oracle-Free container instance from any Java application by using: + + +[Container creation](../../../modules/oracle-free/src/test/java/org/testcontainers/junit/oracle/SimpleOracleTest.java) inside_block:container + + +See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. + +### Testcontainers JDBC URL + +`jdbc:tc:oracle:21-slim-faststart:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-oracle-free:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-oracle-free + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency. + + diff --git a/docs/modules/databases/oraclexe.md b/docs/modules/databases/oraclexe.md new file mode 100644 index 00000000000..8d060ed65be --- /dev/null +++ b/docs/modules/databases/oraclexe.md @@ -0,0 +1,42 @@ +# Oracle-XE Module + +Testcontainers module for [Oracle XE](https://hub.docker.com/r/gvenzl/oracle-xe) + +## Usage example + +You can start an Oracle-XE container instance from any Java application by using: + + +[Container creation](../../../modules/oracle-xe/src/test/java/org/testcontainers/junit/oracle/SimpleOracleTest.java) inside_block:container + + +See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. + +### Testcontainers JDBC URL + +`jdbc:tc:oracle:21-slim-faststart:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-oracle-xe:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-oracle-xe + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency. + + diff --git a/docs/modules/databases/orientdb.md b/docs/modules/databases/orientdb.md new file mode 100644 index 00000000000..f3be7e2bbdb --- /dev/null +++ b/docs/modules/databases/orientdb.md @@ -0,0 +1,50 @@ +# OrientDB Module + +Testcontainers module for [OrientDB](https://hub.docker.com/_/orientdb/) + +## Usage example + +You can start an OrientDB container instance from any Java application by using: + + +[Container creation](../../../modules/orientdb/src/test/java/org/testcontainers/orientdb/OrientDBContainerTest.java) inside_block:container + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-orientdb:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-orientdb + {{latest_version}} + test + + ``` + +!!! hint + Add the OrientDB Java client if you plan to access the Testcontainer: + + === "Gradle" + ```groovy + compile "com.orientechnologies:orientdb-client:3.0.24" + ``` + + === "Maven" + ```xml + + com.orientechnologies + orientdb-client + 3.0.24 + + ``` + + + + diff --git a/docs/modules/databases/postgres.md b/docs/modules/databases/postgres.md new file mode 100644 index 00000000000..f27752b8354 --- /dev/null +++ b/docs/modules/databases/postgres.md @@ -0,0 +1,67 @@ +# Postgres Module + +Testcontainers module for [PostgresSQL](https://hub.docker.com/_/postgres) + +## Usage example + +You can start a PostgreSQL container instance from any Java application by using: + + +[Container creation](../../../modules/postgresql/src/test/java/org/testcontainers/postgresql/PostgreSQLContainerTest.java) inside_block:container + + +See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. + +### Testcontainers JDBC URL + +* PostgreSQL: `jdbc:tc:postgresql:9.6.8:///databasename` +* PostGIS: `jdbc:tc:postgis:9.6-2.5:///databasename` +* TimescaleDB: `jdbc:tc:timescaledb:2.1.0-pg13:///databasename` +* PGvector: `jdbc:tc:pgvector:pg16:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +## Compatible images + +`PostgreSQLContainer` can also be used with the following images: + +* [pgvector/pgvector](https://hub.docker.com/r/pgvector/pgvector) + + +[Using pgvector](../../../modules/postgresql/src/test/java/org/testcontainers/postgresql/CompatibleImageTest.java) inside_block:pgvectorContainer + + +* [postgis/postgis](https://registry.hub.docker.com/r/postgis/postgis) + + +[Using PostGIS](../../../modules/postgresql/src/test/java/org/testcontainers/postgresql/CompatibleImageTest.java) inside_block:postgisContainer + + +* [timescale/timescaledb](https://hub.docker.com/r/timescale/timescaledb) + + +[Using TimescaleDB](../../../modules/postgresql/src/test/java/org/testcontainers/postgresql/CompatibleImageTest.java) inside_block:timescaledbContainer + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-postgresql:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-postgresql + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency. + + diff --git a/docs/modules/databases/presto.md b/docs/modules/databases/presto.md new file mode 100644 index 00000000000..4e9a6e7c95d --- /dev/null +++ b/docs/modules/databases/presto.md @@ -0,0 +1,89 @@ +# Presto Module + +!!! note + This module is deprecated, use Trino module. + +See [Database containers](./index.md) for documentation and usage that is common to all database container types. + +## Usage example + +Running Presto as a stand-in for in a test: + +```java +public class SomeTest { + + @Rule + public PrestoContainer presto = new PrestoContainer(); + + @Test + public void someTestMethod() { + String url = presto.getJdbcUrl(); + + ... create a connection and run test as normal +``` + +Presto comes with several catalogs preconfigured. Most useful ones for testing are + +* `tpch` catalog using the [Presto TPCH Connector](https://prestosql.io/docs/current/connector/tpch.html). + This is a read-only catalog that defines standard TPCH schema, so is available for querying without a need + to create any tables. +* `memory` catalog using the [Presto Memory Connector](https://prestosql.io/docs/current/connector/memory.html). + This catalog can be used for creating schemas and tables and does not require any storage, as everything + is stored fully in-memory. + +Example test using the `tpch` and `memory` catalogs: + +```java +public class SomeTest { + @Rule + public PrestoContainer prestoSql = new PrestoContainer(); + + @Test + public void queryMemoryAndTpchConnectors() throws SQLException { + try (Connection connection = prestoSql.createConnection(); + Statement statement = connection.createStatement()) { + // Prepare data + statement.execute("CREATE TABLE memory.default.table_with_array AS SELECT 1 id, ARRAY[1, 42, 2, 42, 4, 42] my_array"); + + // Query Presto using newly created table and a builtin connector + try (ResultSet resultSet = statement.executeQuery("" + + "SELECT nationkey, element " + + "FROM tpch.tiny.nation " + + "JOIN memory.default.table_with_array twa ON nationkey = twa.id " + + "LEFT JOIN UNNEST(my_array) a(element) ON true " + + "ORDER BY element OFFSET 1 FETCH NEXT 3 ROWS WITH TIES ")) { + List actualElements = new ArrayList<>(); + while (resultSet.next()) { + actualElements.add(resultSet.getInt("element")); + } + Assert.assertEquals(Arrays.asList(2, 4, 42, 42, 42), actualElements); + } + } + } +} +``` + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-presto:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-presto + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add the Presto JDBC driver JAR to your project. + You should ensure that your project has the Presto JDBC driver as a dependency, if you plan on using it. + Refer to [Presto project download page](https://prestosql.io/download.html) for instructions. + + diff --git a/docs/modules/databases/questdb.md b/docs/modules/databases/questdb.md new file mode 100644 index 00000000000..eb5c466f371 --- /dev/null +++ b/docs/modules/databases/questdb.md @@ -0,0 +1,42 @@ +# QuestDB Module + +Testcontainers module for [QuestDB](https://hub.docker.com/r/questdb/questdb) + +## Usage example + +You can start a QuestDB container instance from any Java application by using: + + +[Container creation](../../../modules/questdb/src/test/java/org/testcontainers/junit/questdb/SimpleQuestDBTest.java) inside_block:container + + +See [Database containers](./index.md) for documentation and usage that is common to all relational database container +types. + +### Testcontainers JDBC URL + +`jdbc:tc:questdb:6.5.3:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + +```groovy +testImplementation "org.testcontainers:testcontainers-questdb:{{latest_version}}" +``` + +=== "Maven" + +```xml + + + org.testcontainers + testcontainers-questdb + {{latest_version}} + test + +``` diff --git a/docs/modules/databases/r2dbc.md b/docs/modules/databases/r2dbc.md new file mode 100644 index 00000000000..91580811887 --- /dev/null +++ b/docs/modules/databases/r2dbc.md @@ -0,0 +1,65 @@ +# R2DBC support + +You can obtain a temporary database in one of two ways: + + * **Using a specially modified R2DBC URL**: after making a very simple modification to your system's R2DBC URL string, Testcontainers will provide a disposable stand-in database that can be used without requiring modification to your application code. + * **JUnit @Rule/@ClassRule**: this mode starts a database inside a container before your tests and tears it down afterwards. + +## Database containers launched via R2DBC URL scheme + +As long as you have Testcontainers and the appropriate R2DBC driver on your classpath, you can simply modify regular R2DBC connection URLs to get a fresh containerized instance of the database each time your application starts up. + +The started container will be terminated when the `ConnectionFactory` is closed. + +!!! warning + Both the database module (e.g. `org.testcontainers:testcontainers-mysql`) **and** `org.testcontainers:testcontainers-r2dbc` need to be on your application's classpath at runtime. + +**Original URL**: `r2dbc:mysql://localhost:3306/databasename` + +1. Insert `tc:` after `r2dbc:` as follows. Note that the hostname, port and database name will be ignored; you can leave these as-is or set them to any value. +1. Specify the mandatory Docker tag of the database's official image that you want using a `TC_IMAGE_TAG` query parameter. + +**Note that, unlike Testcontainers' JDBC URL support, it is not possible to specify an image tag in the 'scheme' part of the URL, and it is always necessary to specify a tag using `TC_IMAGE_TAG`.** + +So that the URL becomes: +`r2dbc:tc:mysql:///databasename?TC_IMAGE_TAG=8.0.36` + +!!! note + We will use `///` (host-less URIs) from now on to emphasis the unimportance of the `host:port` pair. + From Testcontainers' perspective, `r2dbc:mysql://localhost:3306/databasename` and `r2dbc:mysql:///databasename` is the same URI. + +!!! warning + If you're using the R2DBC URL support, there is no need to instantiate an instance of the container - Testcontainers will do it automagically. + +### R2DBC URL examples + +#### Using ClickHouse + +`r2dbc:tc:clickhouse:///databasename?TC_IMAGE_TAG=21.11.11-alpine` + +#### Using MySQL + +`r2dbc:tc:mysql:///databasename?TC_IMAGE_TAG=8.0.36` + +#### Using MariaDB + +`r2dbc:tc:mariadb:///databasename?TC_IMAGE_TAG=10.3.39` + +#### Using PostgreSQL + +`r2dbc:tc:postgresql:///databasename?TC_IMAGE_TAG=9.6.8` + +#### Using MSSQL: + +`r2dbc:tc:sqlserver:///?TC_IMAGE_TAG=2017-CU12` + +#### Using Oracle: + +`r2dbc:tc:oracle:///?TC_IMAGE_TAG=21-slim-faststart` + +## Obtaining `ConnectionFactoryOptions` from database container objects + +If you already have an instance of the database container, you can get an instance of `ConnectionFactoryOptions` from it: + +[Creating `ConnectionFactoryOptions` from an instance)](../../../modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerTest.java) inside_block:get_options + diff --git a/docs/modules/databases/scylladb.md b/docs/modules/databases/scylladb.md new file mode 100644 index 00000000000..bde40e3b599 --- /dev/null +++ b/docs/modules/databases/scylladb.md @@ -0,0 +1,58 @@ +# ScyllaDB + +Testcontainers module for [ScyllaDB](https://hub.docker.com/r/scylladb/scylla) + +## ScyllaDB's usage examples + +You can start a ScyllaDB container instance from any Java application by using: + + +[Create container](../../../modules/scylladb/src/test/java/org/testcontainers/scylladb/ScyllaDBContainerTest.java) inside_block:container + + + +[Custom config file](../../../modules/scylladb/src/test/java/org/testcontainers/scylladb/ScyllaDBContainerTest.java) inside_block:customConfiguration + + +### Building CqlSession + + +[Using CQL port](../../../modules/scylladb/src/test/java/org/testcontainers/scylladb/ScyllaDBContainerTest.java) inside_block:session + + + +[Using SSL](../../../modules/scylladb/src/test/java/org/testcontainers/scylladb/ScyllaDBContainerTest.java) inside_block:sslContext + + + +[Using Shard Awareness port](../../../modules/scylladb/src/test/java/org/testcontainers/scylladb/ScyllaDBContainerTest.java) inside_block:shardAwarenessSession + + +### Alternator + + +[Enabling Alternator](../../../modules/scylladb/src/test/java/org/testcontainers/scylladb/ScyllaDBContainerTest.java) inside_block:alternator + + + +[DynamoDbClient with Alternator](../../../modules/scylladb/src/test/java/org/testcontainers/scylladb/ScyllaDBContainerTest.java) inside_block:dynamodDbClient + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-scylladb:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-scylladb + {{latest_version}} + test + + ``` diff --git a/docs/modules/databases/tidb.md b/docs/modules/databases/tidb.md new file mode 100644 index 00000000000..17cb66b21c3 --- /dev/null +++ b/docs/modules/databases/tidb.md @@ -0,0 +1,41 @@ +# TiDB Module + +Testcontainers module for [TiDB](https://hub.docker.com/r/pingcap/tidb) + +## Usage example + +You can start a TiDB container instance from any Java application by using: + + +[Container creation](../../../modules/tidb/src/test/java/org/testcontainers/tidb/TiDBContainerTest.java) inside_block:container + + +See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. + +### Testcontainers JDBC URL + +`jdbc:tc:tidb:v6.1.0:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-tidb:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-tidb + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency. diff --git a/docs/modules/databases/timeplus.md b/docs/modules/databases/timeplus.md new file mode 100644 index 00000000000..ee5e5767223 --- /dev/null +++ b/docs/modules/databases/timeplus.md @@ -0,0 +1,40 @@ +# Timeplus Module + +Testcontainers module for [Timeplus](https://hub.docker.com/r/timeplus/timeplusd) + +## Usage example + +You can start a Timeplus container instance from any Java application by using: + + +[Container creation](../../../modules/timeplus/src/test/java/org/testcontainers/timeplus/TimeplusContainerTest.java) inside_block:container + + +### Testcontainers JDBC URL + +`jdbc:tc:timeplus:2.3.21:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-timeplus:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-timeplus + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency. + diff --git a/docs/modules/databases/trino.md b/docs/modules/databases/trino.md new file mode 100644 index 00000000000..81fb4ea5b98 --- /dev/null +++ b/docs/modules/databases/trino.md @@ -0,0 +1,45 @@ +# Trino Module + +Testcontainers module for [Trino](https://hub.docker.com/r/trinodb/trino) + +## Usage example + +You can start a Trino container instance from any Java application by using: + + +[Container creation](../../../modules/trino/src/test/java/org/testcontainers/trino/TrinoContainerTest.java) inside_block:container + + +See [Database containers](./index.md) for documentation and usage that is common to all database container types. + +### Testcontainers JDBC URL + +`jdbc:tc:trino:352:///defaultname` + +See [JDBC](./jdbc.md) for documentation. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-trino:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-trino + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add the Trino JDBC driver JAR to your project. + You should ensure that your project has the Trino JDBC driver as a dependency, if you plan on using it. + Refer to [Trino project download page](https://trino.io/download.html) for instructions. + + diff --git a/docs/modules/databases/yugabytedb.md b/docs/modules/databases/yugabytedb.md new file mode 100644 index 00000000000..4409712241b --- /dev/null +++ b/docs/modules/databases/yugabytedb.md @@ -0,0 +1,54 @@ +# YugabyteDB Module + +Testcontainers module for [YugabyteDB](https://hub.docker.com/r/yugabytedb/yugabyte) + +See [Database containers](./index.md) for documentation and usage that is common to all database container types. + +YugabyteDB supports two APIs. + +- Yugabyte Structured Query Language [YSQL](https://docs.yugabyte.com/latest/api/ysql/) is a fully-relational API that is built by the PostgreSQL code +- Yugabyte Cloud Query Language [YCQL](https://docs.yugabyte.com/latest/api/ycql/) is a semi-relational SQL API that has its roots in the Cassandra Query Language + +## Usage example + +### YSQL API + + +[Creating a YSQL container](../../../modules/yugabytedb/src/test/java/org/testcontainers/junit/yugabytedb/YugabyteDBYSQLTest.java) inside_block:creatingYSQLContainer + + +### Testcontainers JDBC URL + +`jdbc:tc:yugabyte:2.14.4.0-b26:///databasename` + +See [JDBC](./jdbc.md) for documentation. + +### YCQL API + + +[Creating a YCQL container](../../../modules/yugabytedb/src/test/java/org/testcontainers/junit/yugabytedb/YugabyteDBYCQLTest.java) inside_block:creatingYCQLContainer + + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-yugabytedb:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-yugabytedb + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add the Yugabytedb driver JAR to your project. + You should ensure that your project has the Yugabytedb driver as a dependency, if you plan on using it. + Refer to the driver page [YSQL](https://docs.yugabyte.com/latest/integrations/jdbc-driver/) and [YCQL](https://docs.yugabyte.com/latest/reference/drivers/ycql-client-drivers/) for instructions. diff --git a/docs/modules/docker_compose.md b/docs/modules/docker_compose.md new file mode 100644 index 00000000000..3d3d7a510c2 --- /dev/null +++ b/docs/modules/docker_compose.md @@ -0,0 +1,189 @@ +# Docker Compose Module + +## Benefits + +Similar to generic container support, it's also possible to run a bespoke set of services specified in a +`docker-compose.yml` file. + +This is especially useful for projects where Docker Compose is already used in development +or other environments to define services that an application may be dependent upon. + +The `ComposeContainer` leverages [Compose V2](https://www.docker.com/blog/announcing-compose-v2-general-availability/), +making it easy to use the same dependencies from the development environment within tests. + +## Example + +A single class `ComposeContainer`, defined based on a `docker-compose.yml` file, +should be sufficient to launch any number of services required by our tests: + + +[Create a ComposeContainer](../../core/src/test/java/org/testcontainers/junit/ComposeContainerTest.java) inside_block:composeContainerConstructor + + +!!! note + Make sure the service names use a `-` rather than `_` as separator. + +In this example, Docker Compose file should have content such as: +```yaml +services: + redis: + image: redis + db: + image: mysql:8.0.36 +``` + +Note that it is not necessary to define ports to be exposed in the YAML file, +as this would inhibit the reuse/inclusion of the file in other contexts. + +Instead, Testcontainers will spin up a small `ambassador` container, +which will proxy between the Compose-managed containers and ports that are accessible to our tests. + +## ComposeContainer vs DockerComposeContainer + +So far, we discussed `ComposeContainer`, which supports docker compose [version 2](https://www.docker.com/blog/announcing-compose-v2-general-availability/). + +On the other hand, `DockerComposeContainer` utilizes Compose V1, which has been marked deprecated by Docker. + +The two APIs are quite similar, and most examples provided on this page can be applied to both of them. + +## Accessing a Container + +`ComposeContainer` provides methods for discovering how your tests can interact with the containers: + +* `getServiceHost(serviceName, servicePort)` returns the IP address where the container is listening (via an ambassador + container) +* `getServicePort(serviceName, servicePort)` returns the Docker mapped port for a port that has been exposed (via an + ambassador container) + +Let's use this API to create the URL that will enable our tests to access the Redis service: + +[Access a Service's host and port](../../core/src/test/java/org/testcontainers/junit/ComposeContainerTest.java) inside_block:getServiceHostAndPort + + +## Wait Strategies and Startup Timeouts +Ordinarily Testcontainers will wait for up to 60 seconds for each exposed container's first mapped network port to start listening. +This simple measure provides a basic check whether a container is ready for use. + +There are overloaded `withExposedService` methods that take a `WaitStrategy` +where we can specify a timeout strategy per container. + +We can either use the fluent API to crate a [custom strategy](../features/startup_and_waits.md) or use one of the already existing ones, +accessible via the static factory methods from of the `Wait` class. + +For instance, we can wait for exposed port and set a custom timeout: + +[Wait for the exposed port and use a custom timeout](../../core/src/test/java/org/testcontainers/junit/ComposeContainerWithWaitStrategiesTest.java) inside_block:composeContainerWaitForPortWithTimeout + + +Needless to say, we can define different strategies for each service in our Docker Compose setup. + +For example, our Redis container can wait for a successful redis-cli command, +while our db service waits for a specific log message: + + +[Wait for a custom command and a log message](../../core/src/test/java/org/testcontainers/junit/ComposeContainerWithWaitStrategiesTest.java) inside_block:composeContainerWithCombinedWaitStrategies + + + + +## The 'Local Compose' Mode + +We can override Testcontainers' default behaviour and make it use a `docker-compose` binary installed on the local machine. + +This will generally yield an experience that is closer to running _docker compose_ locally, +with the caveat that Docker Compose needs to be present on dev and CI machines. + + +[Use ComposeContainer in 'Local Compose' mode](../../core/src/test/java/org/testcontainers/containers/ComposeProfilesOptionTest.java) inside_block:composeContainerWithLocalCompose + + +## Build Working Directory + +We can select what files should be copied only via `withCopyFilesInContainer`: + + +[Use ComposeContainer in 'Local Compose' mode](../../core/src/test/java/org/testcontainers/junit/ComposeContainerWithCopyFilesTest.java) inside_block:composeContainerWithCopyFiles + + +In this example, only docker compose and env files are copied over into the container that will run the Docker Compose file. +By default, all files in the same directory as the compose file are copied over. + +We can use file and directory references. +They are always resolved relative to the directory where the compose file resides. + +!!! note + This can be used with `DockerComposeContainer` and `ComposeContainer`, but **only in the containerized Compose (not with `Local Compose` mode)**. + +## Using private repositories in Docker compose +When Docker Compose is used in container mode (not local), it needs to be made aware of Docker +settings for private repositories. +By default, those setting are located in `$HOME/.docker/config.json`. + +There are 3 ways to specify location of the `config.json` for Docker Compose: + +* Use `DOCKER_CONFIG_FILE` environment variable. + + `export DOCKER_CONFIG_FILE=/some/location/config.json` + +* Use `dockerConfigFile` java property + + `java -DdockerConfigFile=/some/location/config.json` + +* Don't specify anything. In this case default location `$HOME/.docker/config.json`, if present, will be used. + +!!! note "Docker Compose and Credential Store / Credential Helpers" + Modern Docker tends to store credentials using the credential store/helper mechanism rather than storing credentials in Docker's configuration file. So, your `config.json` may look something like: + + ```json + { + "auths" : { + "https://index.docker.io/v1/" : { + } + }, + "credsStore" : "osxkeychain" + } + ``` + + When run inside a container, Docker Compose cannot access the Keychain, thus making the configuration useless. + To work around this problem, there are two options: + + ##### Putting auths in a config file + Create a `config.json` in separate location with real authentication keys, like: + + ```json + { + "auths" : { + "https://index.docker.io/v1/" : { + "auth": "QWEADSZXC..." + } + }, + "credsStore" : "osxkeychain" + } + ``` + and specify the location to Testcontainers using any of the two first methods from above. + + ##### Using 'local compose' mode + + [Local Compose mode](#local-compose-mode), mentioned above, will allow compose to directly access the Docker auth system (to the same extent that running the `docker-compose` CLI manually works). + + +## Adding this module to your project dependencies + +*Docker Compose support is part of the core Testcontainers library.* + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers + {{latest_version}} + test + + ``` + diff --git a/docs/modules/docker_mcp_gateway.md b/docs/modules/docker_mcp_gateway.md new file mode 100644 index 00000000000..af526bab3fe --- /dev/null +++ b/docs/modules/docker_mcp_gateway.md @@ -0,0 +1,32 @@ +# Docker MCP Gateway + +Testcontainers module for [Docker MCP Gateway](https://hub.docker.com/r/docker/mcp-gateway). + +## DockerMcpGatewayContainer's usage examples + +You can start a Docker MCP Gateway container instance from any Java application by using: + + +[Create a DockerMcpGatewayContainer](../../core/src/test/java/org/testcontainers/containers/DockerMcpGatewayContainerTest.java) inside_block:container + + +## Adding this module to your project dependencies + +*Docker MCP Gateway support is part of the core Testcontainers library.* + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers + {{latest_version}} + test + + ``` + diff --git a/docs/modules/docker_model_runner.md b/docs/modules/docker_model_runner.md new file mode 100644 index 00000000000..b610279e93b --- /dev/null +++ b/docs/modules/docker_model_runner.md @@ -0,0 +1,41 @@ +# Docker Model Runner + +This module helps connect to [Docker Model Runner](https://docs.docker.com/desktop/features/model-runner/) +provided by Docker Desktop 4.40.0. + +## DockerModelRunner's usage examples + +You can start a Docker Model Runner proxy container instance from any Java application by using: + + +[Create a DockerModelRunnerContainer](../../core/src/test/java/org/testcontainers/containers/DockerModelRunnerContainerTest.java) inside_block:container + + +### Pulling the model + +Pulling the model is as simple as: + + +[Pull model](../../core/src/test/java/org/testcontainers/containers/DockerModelRunnerContainerTest.java) inside_block:pullModel + + +## Adding this module to your project dependencies + +*Docker Model Runner support is part of the core Testcontainers library.* + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers + {{latest_version}} + test + + ``` + diff --git a/docs/modules/elasticsearch.md b/docs/modules/elasticsearch.md new file mode 100644 index 00000000000..5817a21de02 --- /dev/null +++ b/docs/modules/elasticsearch.md @@ -0,0 +1,77 @@ +# Elasticsearch container + +This module helps running [elasticsearch](https://www.elastic.co/products/elasticsearch) using +Testcontainers. + +Note that it's based on the [official Docker image](https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html) provided by elastic. + +## Usage example + +You can start an elasticsearch container instance from any Java application by using: + + +[HttpClient](../../modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java) inside_block:httpClientContainer7 +[HttpClient with Elasticsearch 8](../../modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java) inside_block:httpClientContainer8 +[HttpClient with Elasticsearch 8 and SSL disabled](../../modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java) inside_block:httpClientContainerNoSSL8 +[TransportClient](../../modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java) inside_block:transportClientContainer + + + +Note that if you are still using the [TransportClient](https://www.elastic.co/guide/en/elasticsearch/client/java-api/current/transport-client.html) +(not recommended as it is deprecated), the default cluster name is set to `docker-cluster` so you need to change `cluster.name` setting +or set `client.transport.ignore_cluster_name` to `true`. + +## Secure your Elasticsearch cluster + +The default distribution of Elasticsearch comes with the basic license which contains security feature. +You can turn on security by providing a password: + + +[HttpClient](../../modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java) inside_block:httpClientSecuredContainer + + +## Kibana container + +This module also provides a `KibanaContainer` for testing with [Kibana](https://www.elastic.co/kibana). +Kibana requires a connection to Elasticsearch and `KibanaContainer` supports two modes: managed and external. + +### Managed mode + +In managed mode, `KibanaContainer` automatically connects to an `ElasticsearchContainer`: + + +[Kibana with Elasticsearch](../../modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java) inside_block:managedModeCanStartAndReachElasticsearchInSameExplicitNetwork + + +When using managed mode with explicit networks, both containers must share the same `Network` instance. +Alternatively, you can omit the network configuration entirely, and `KibanaContainer` will do its best effort to create a shared, ad-hoc network automatically. + +### External mode + +In external mode, `KibanaContainer` connects to an external Elasticsearch instance via URL and using provided credentials: + + +[Kibana with external Elasticsearch](../../modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java) inside_block:externalModeCanWorkWithUsernamePassword + + +For external mode with HTTPS, use `withElasticsearchCaCertificate()` to provide the CA certificate. +You can authenticate using either username/password (`withElasticsearchCredentials()`) or service account tokens (`withElasticsearchServiceAccountToken()`). + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-elasticsearch:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-elasticsearch + {{latest_version}} + test + + ``` diff --git a/docs/modules/gcloud.md b/docs/modules/gcloud.md new file mode 100644 index 00000000000..9660461d5a0 --- /dev/null +++ b/docs/modules/gcloud.md @@ -0,0 +1,169 @@ +# GCloud Module + +!!! note + This module is INCUBATING. While it is ready for use and operational in the current version of Testcontainers, it is possible that it may receive breaking changes in the future. See [our contributing guidelines](/contributing/#incubating-modules) for more information on our incubating modules policy. + +Testcontainers module for the Google Cloud Platform's [Cloud SDK](https://cloud.google.com/sdk/). + +Currently, the module supports `BigQuery`, `Bigtable`, `Datastore`, `Firestore`, `Spanner`, and `Pub/Sub` emulators. In order to use it, you should use the following classes: + +Class | Container Image +-|- +BigQueryEmulatorContainer | [ghcr.io/goccy/bigquery-emulator](https://ghcr.io/goccy/bigquery-emulator) +BigtableEmulatorContainer | [gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators](https://gcr.io/google.com/cloudsdktool/google-cloud-cli) +DatastoreEmulatorContainer | [gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators](https://gcr.io/google.com/cloudsdktool/google-cloud-cli) +FirestoreEmulatorContainer | [gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators](https://gcr.io/google.com/cloudsdktool/google-cloud-cli) +SpannerEmulatorContainer | [gcr.io/cloud-spanner-emulator/emulator](https://gcr.io/cloud-spanner-emulator/emulator) +PubSubEmulatorContainer | [gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators](https://gcr.io/google.com/cloudsdktool/google-cloud-cli) + +## Usage example + +### BigQuery + +Start BigQuery Emulator during a test: + + +[Starting a BigQuery Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/BigQueryEmulatorContainerTest.java) inside_block:emulatorContainer + + + +[Creating BigQuery Client](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/BigQueryEmulatorContainerTest.java) inside_block:bigQueryClient + + +### Bigtable + +Start Bigtable Emulator during a test: + + +[Starting a Bigtable Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/BigtableEmulatorContainerTest.java) inside_block:emulatorContainer + + +Create a test Bigtable table in the Emulator: + + +[Create a test table](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/BigtableEmulatorContainerTest.java) inside_block:createTable + + +Test against the Emulator: + + +[Testing with a Bigtable Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/BigtableEmulatorContainerTest.java) inside_block:testWithEmulatorContainer + + +### Datastore + +Start Datastore Emulator during a test: + + +[Starting a Datastore Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/DatastoreEmulatorContainerTest.java) inside_block:creatingDatastoreEmulatorContainer + + +And test against the Emulator: + + +[Testing with a Datastore Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/DatastoreEmulatorContainerTest.java) inside_block:startingDatastoreEmulatorContainer + + +See more examples: + + * [Full sample code](https://github.com/testcontainers/testcontainers-java/tree/main/modules/gcloud/src/test/java/org/testcontainers/gcloud/DatastoreEmulatorContainerTest.java) + * [With Spring Boot](https://github.com/saturnism/testcontainers-gcloud-examples/tree/main/springboot/datastore-example/src/test/java/com/example/springboot/datastore) + +### Firestore + +Start Firestore Emulator during a test: + + +[Starting a Firestore Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/FirestoreEmulatorContainerTest.java) inside_block:emulatorContainer + + +And test against the Emulator: + + +[Testing with a Firestore Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/FirestoreEmulatorContainerTest.java) inside_block:testWithEmulatorContainer + + +See more examples: + + * [Full sample code](https://github.com/testcontainers/testcontainers-java/tree/main/modules/gcloud/src/test/java/org/testcontainers/gcloud/FirestoreEmulatorContainerTest.java) + * [With Spring Boot](https://github.com/saturnism/testcontainers-gcloud-examples/tree/main/springboot/firestore-example/src/test/java/com/example/springboot/firestore/FirestoreIntegrationTests.java) + +### Spanner + +Start Spanner Emulator during a test: + + +[Starting a Spanner Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/SpannerEmulatorContainerTest.java) inside_block:emulatorContainer + + +Create a test Spanner Instance in the Emulator: + + +[Create a test Spanner instance](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/SpannerEmulatorContainerTest.java) inside_block:createInstance + + +Create a test Database in the Emulator: + + +[Creating a test Spanner database](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/SpannerEmulatorContainerTest.java) inside_block:createDatabase + + +And test against the Emulator: + + +[Testing with a Spanner Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/SpannerEmulatorContainerTest.java) inside_block:testWithEmulatorContainer + + +See more examples: + + * [Full sample code](https://github.com/testcontainers/testcontainers-java/tree/main/modules/gcloud/src/test/java/org/testcontainers/gcloud/SpannerEmulatorContainerTest.java) + * [With Spring Boot](https://github.com/saturnism/testcontainers-gcloud-examples/tree/main/springboot/spanner-example/src/test/java/com/example/springboot/spanner/SpannerIntegrationTests.java) + +### Pub/Sub + +Start Pub/Sub Emulator during a test: + + +[Starting a Pub/Sub Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/PubSubEmulatorContainerTest.java) inside_block:emulatorContainer + + +Create a test Pub/Sub topic in the Emulator: + + +[Create a test topic](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/PubSubEmulatorContainerTest.java) inside_block:createTopic + + +Create a test Pub/Sub subscription in the Emulator: + + +[Create a test subscription](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/PubSubEmulatorContainerTest.java) inside_block:createSubscription + + +And test against the Emulator: + + +[Testing with a Pub/Sub Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/PubSubEmulatorContainerTest.java) inside_block:testWithEmulatorContainer + + +See more examples: + + * [Full sample code](https://github.com/testcontainers/testcontainers-java/tree/main/modules/gcloud/src/test/java/org/testcontainers/gcloud/PubSubEmulatorContainerTest.java) + * [With Spring Boot](https://github.com/saturnism/testcontainers-gcloud-examples/tree/main/springboot/pubsub-example/src/test/java/com/example/springboot/pubsub/PubSubIntegrationTests.java) + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-gcloud:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-gcloud + {{latest_version}} + test + + ``` diff --git a/docs/modules/grafana.md b/docs/modules/grafana.md new file mode 100644 index 00000000000..397ade89ee3 --- /dev/null +++ b/docs/modules/grafana.md @@ -0,0 +1,30 @@ +# Grafana + +Testcontainers module for [Grafana OTel LGTM](https://hub.docker.com/r/grafana/otel-lgtm). + +## LGTM's usage examples + +You can start a Grafana OTel LGTM container instance from any Java application by using: + + +[Grafana Otel LGTM container](../../modules/grafana/src/test/java/org/testcontainers/grafana/LgtmStackContainerTest.java) inside_block:container + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-grafana:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-grafana + {{latest_version}} + test + + ``` diff --git a/docs/modules/hivemq.md b/docs/modules/hivemq.md new file mode 100644 index 00000000000..8bba4c18b36 --- /dev/null +++ b/docs/modules/hivemq.md @@ -0,0 +1,235 @@ +# HiveMQ Module + +![hivemq logo](../modules_logos/hivemq-module.png) + +Automatic starting HiveMQ docker containers for JUnit5 tests. +This enables testing MQTT client applications and integration testing of custom HiveMQ extensions. + +- Community forum: https://community.hivemq.com/ +- HiveMQ website: https://www.hivemq.com/ +- MQTT resources: + - [MQTT Essentials](https://www.hivemq.com/mqtt-essentials/) + - [MQTT 5 Essentials](https://www.hivemq.com/mqtt-5/) + +Please make sure to check out the hivemq-docs for the [Community Edition](https://github.com/hivemq/hivemq-community-edition/wiki/) +and the [Enterprise Edition](https://www.hivemq.com/docs/hivemq/4.7/user-guide/). + +## Using HiveMQ CE/EE + +HiveMQ provides different editions of on [Docker Hub](https://hub.docker.com/u/hivemq): + +- the open source [Community Edition](https://github.com/hivemq/hivemq-community-edition) which +is published as *hivemq/hivemq-ce*. +- the [Enterprise Edition](https://www.hivemq.com/docs/hivemq/4.7/user-guide/) which is published as *hivemq/hivemq4*. + +Both editions can be used directly: + +Using the Community Edition: + +[Community Edition HiveMQ image](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoHiveMQContainerIT.java) inside_block:ceVersion + + +Using the Enterprise Edition: + +[Enterprise Edition HiveMQ image](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoHiveMQContainerIT.java) inside_block:hiveEEVersion + + +Using a specific version is possible by using the tag: + +[Specific HiveMQ Version](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoHiveMQContainerIT.java) inside_block:specificVersion + + +## Test your MQTT 3 and MQTT 5 client application + +Using an Mqtt-client (e.g. the [HiveMQ-Mqtt-Client](https://github.com/hivemq/hivemq-mqtt-client)) you can start +testing directly. + + +[MQTT5 Client](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoHiveMQContainerIT.java) inside_block:mqtt5client + + +## Settings + +There are several things that can be adjusted before container setup. +The following example shows how to enable the Control Center (this is an enterprise feature), set the log level to DEBUG +and load a HiveMQ-config-file from the classpath. + + +[Config Examples](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoHiveMQContainerIT.java) inside_block:eeVersionWithControlCenter + + +--- +**Note:** +The Control Center of HiveMQ can be accessed via the URL presented in the output of the starting container: + +``` +2021-09-10 10:35:53,511 INFO - The HiveMQ Control Center is reachable under: http://localhost:55032 +``` + +Please be aware that the Control Center is a feature of the enterprise edition of HiveMQ and thus only available with +the enterprise image. + +--- + +## Testing HiveMQ extensions + +Using the [Extension SDK](https://github.com/hivemq/hivemq-extension-sdk) the functionality of all editions of HiveMQ +can be extended. +The HiveMQ module also supports testing your own custom extensions. + +### Wait Strategy + +The raw HiveMQ module is built to wait for certain startup log messages to signal readiness. +Since extensions are loaded dynamically they can be available a short while after the main container has started. +We therefore provide custom wait conditions for HiveMQ Extensions: + +The following will specify an extension to be loaded from **src/test/resources/modifier-extension** into the container and +wait for an extension named **'My Extension Name'** to be started: + + +[Custom Wait Strategy](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoExtensionTestsIT.java) inside_block:waitStrategy + + +Next up we have an example for using an extension directly from the classpath and waiting directly on the extension: + + +[Extension from Classpath](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoExtensionTestsIT.java) inside_block:extensionClasspath + + +--- +**Note** Debugging extensions + +Both examples contain ```.withDebugging()``` which enables remote debugging on the container. +With debugging enabled you can start putting breakpoints right into your extensions. + +--- + +### Testing extensions using Gradle + +In a Gradle based HiveMQ Extension project, testing is supported using the dedicated [HiveMQ Extension Gradle Plugin](https://github.com/hivemq/hivemq-extension-gradle-plugin/README.md). + +The plugin adds an `integrationTest` task which executes tests from the `integrationTest` source set. +- Integration test source files are defined in `src/integrationTest`. +- Integration test dependencies are defined via the `integrationTestImplementation`, `integrationTestRuntimeOnly`, etc. configurations. + +The `integrationTest` task builds the extension and unzips it to the `build/hivemq-extension-test` directory. +The tests can then load the built extension into the HiveMQ Testcontainer. + + +[Extension from filesystem](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoDisableExtensionsIT.java) inside_block:startFromFilesystem + + +### Enable/Disable an extension + +It is possible to enable and disable HiveMQ extensions during runtime. Extensions can also be disabled on startup. + +--- +**Note**: that disabling or enabling of extension during runtime is only supported in HiveMQ 4 Enterprise Edition Containers. + +--- + +The following example shows how to start a HiveMQ container with the extension called **my-extension** being disabled. + + + +[Disable Extension at startup](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoDisableExtensionsIT.java) inside_block:startDisabled + + +The following test then proceeds to enable and then disable the extension: + + +[Enable/Disable extension at runtime](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoDisableExtensionsIT.java) inside_block:hiveRuntimeEnable + + +## Enable/Disable an extension loaded from a folder + +Extensions loaded from an extension folder during runtime can also be enabled/disabled on the fly. +If the extension folder contains a DISABLED file, the extension will be disabled during startup. + +--- +**Note**: that disabling or enabling of extension during runtime is only supported in HiveMQ 4 Enterprise Edition Containers. + +--- + +We first load the extension from the filesystem: + +[Extension from filesystem](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoDisableExtensionsIT.java) inside_block:startFromFilesystem + + +Now we can enable/disable the extension using its name: + + +[Enable/Disable extension at runtime](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoDisableExtensionsIT.java) inside_block:runtimeEnableFilesystem + + +### Remove prepackaged HiveMQ Extensions + +Since HiveMQ's 4.4 release, HiveMQ Docker images come with the HiveMQ Extension for Kafka, the HiveMQ Enterprise Bridge Extension +and the HiveMQ Enterprise Security Extension. +These Extensions are disabled by default, but sometimes you my need to remove them before the container starts. + +Removing all extension is as simple as: + + +[Remove all extensions](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoDisableExtensionsIT.java) inside_block:noExtensions + + +A single extension (e.g. Kafka) can be removed as easily: + +[Remove a specific extension](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoDisableExtensionsIT.java) inside_block:noKafkaExtension + + +## Put files into the container + +### Put a file into HiveMQ home + + +[Put file into HiveMQ home](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoFilesIT.java) inside_block:hivemqHome + + +### Put files into extension home + + +[Put file into HiveMQ-Extension home](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoFilesIT.java) inside_block:extensionHome + + +### Put license files into the container + + +[Put license file into the HiveMQ container](../../modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoFilesIT.java) inside_block:withLicenses + + + +### Customize the Container further + +Since the `HiveMQContainer` extends from [Testcontainer's](https://github.com/testcontainers) `GenericContainer` the container +can be customized as desired. + +## Add to your project + +### Gradle + +Add to `build.gradle`: + +````groovy +testImplementation 'org.testcontainers:testcontainers-hivemq:{{latest_version}}' +```` + +Add to `build.gradle.kts`: + +````kotlin +testImplementation("org.testcontainers:testcontainers-hivemq:{{latest_version}}") +```` + +### Maven + +Add to `pom.xml`: + +```xml + + org.testcontainers + testcontainers-hivemq + {{latest_version}} + test + +``` diff --git a/docs/modules/k3s.md b/docs/modules/k3s.md new file mode 100644 index 00000000000..a9de1b3fd4f --- /dev/null +++ b/docs/modules/k3s.md @@ -0,0 +1,57 @@ +# K3s Module + +!!! note + This module is INCUBATING. While it is ready for use and operational in the current version of Testcontainers, it is possible that it may receive breaking changes in the future. See [our contributing guidelines](/contributing/#incubating-modules) for more information on our incubating modules policy. + +Testcontainers module for Rancher's [K3s](https://rancher.com/products/k3s/) lightweight Kubernetes. +This module is intended to be used for testing components that interact with Kubernetes APIs - for example, operators. + +## Usage example + +Start a K3s server as follows: + + +[Starting a K3S server](../../modules/k3s/src/test/java/org/testcontainers/k3s/Fabric8K3sContainerTest.java) inside_block:starting_k3s + + +### Connecting to the server + +`K3sContainer` exposes a working Kubernetes client configuration, as a YAML String, via the `getKubeConfigYaml()` method. + +This may be used with Kubernetes clients - e.g. for the [official Java client](connecting_with_k8sio) and +[the Fabric8 Kubernetes client](https://github.com/fabric8io/kubernetes-client): + + +[Official Java client](../../modules/k3s/src/test/java/org/testcontainers/k3s/OfficialClientK3sContainerTest.java) inside_block:connecting_with_k8sio +[Fabric8 Kubernetes client](../../modules/k3s/src/test/java/org/testcontainers/k3s/Fabric8K3sContainerTest.java) inside_block:connecting_with_fabric8 + + +## Known limitations + +!!! warning + * K3sContainer runs as a privileged container and needs to be able to spawn its own containers. For these reasons, + K3sContainer will not work in certain rootless Docker, Docker-in-Docker, or other environments where privileged + containers are disallowed. + + * k3s containers may be unable to run on host machines where `/var/lib/docker` is on a BTRFS filesystem. See [k3s-io/k3s#4863](https://github.com/k3s-io/k3s/issues/4863) for an example. + + * You may experience PKIX exceptions when trying to use a configured Fabric8 client. This is down to newer distributions of k3s issuing elliptic curve keys. + This can be fixed by adding [BouncyCastle PKI library](https://mvnrepository.com/artifact/org.bouncycastle/bcpkix-jdk15on) to your classpath. + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-k3s:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-k3s + {{latest_version}} + test + + ``` diff --git a/docs/modules/k6.md b/docs/modules/k6.md new file mode 100644 index 00000000000..13e0ddc9252 --- /dev/null +++ b/docs/modules/k6.md @@ -0,0 +1,50 @@ +# k6 Module + +!!! note + This module is INCUBATING. + While it is ready for use and operational in the current version of Testcontainers, it is possible that it may receive breaking changes in the future. + See [our contributing guidelines](/contributing/#incubating-modules) for more information on our incubating modules policy. + +Testcontainers module for [k6](https://registry.hub.docker.com/r/grafana/k6). + +[k6](https://k6.io/) is an extensible reliability testing tool built for developer happiness. + +## Basic script execution + +You can start a K6 container instance from any Java application by using: + + +[Setup the container](../../modules/k6/src/test/java/org/testcontainers/k6/K6ContainerTests.java) inside_block:standard_k6 + + +The test above uses a simple k6 script, `test.js`, with command line options and an injected script variable. + +Once the container is started, you can wait for the test results to be collected: + + +[Wait for test results](../../modules/k6/src/test/java/org/testcontainers/k6/K6ContainerTests.java) inside_block:wait + + +Create a simple k6 test script to be executed as part of your tests: + + +[Content of `scripts/test.js`](../../modules/k6/src/test/resources/scripts/test.js) inside_block:access_script_vars + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-k6:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-k6 + {{latest_version}} + test + + ``` diff --git a/docs/modules/kafka.md b/docs/modules/kafka.md new file mode 100644 index 00000000000..cb2906265d0 --- /dev/null +++ b/docs/modules/kafka.md @@ -0,0 +1,99 @@ +# Kafka Module + +Testcontainers can be used to automatically instantiate and manage [Apache Kafka](https://kafka.apache.org) containers. + +Currently, two different Kafka images are supported: + +* `org.testcontainers.kafka.ConfluentKafkaContainer` supports +[confluentinc/cp-kafka](https://hub.docker.com/r/confluentinc/cp-kafka/) +* `org.testcontainers.kafka.KafkaContainer` supports [apache/kafka](https://hub.docker.com/r/apache/kafka/) and [apache/kafka-native](https://hub.docker.com/r/apache/kafka-native/) + +!!! note + `org.testcontainers.containers.KafkaContainer` is deprecated. + Please use `org.testcontainers.kafka.ConfluentKafkaContainer` or `org.testcontainers.kafka.KafkaContainer` instead, depending on the used image. + +## Benefits + +* Running a single node Kafka installation with just one line of code +* No need to manage external Zookeeper installation, required by Kafka. + +## Example + +### Using org.testcontainers.kafka.KafkaContainer + +Create a `KafkaContainer` to use it in your tests: + + +[Creating a KafkaContainer](../../modules/kafka/src/test/java/org/testcontainers/kafka/KafkaContainerTest.java) inside_block:constructorWithVersion + + +Now your tests or any other process running on your machine can get access to running Kafka broker by using the following bootstrap server location: + + +[Bootstrap Servers](../../modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java) inside_block:getBootstrapServers + + +### Using org.testcontainers.kafka.ConfluentKafkaContainer + +!!! note + Compatible with `confluentinc/cp-kafka` images version `7.4.0` and later. + +Create a `ConfluentKafkaContainer` to use it in your tests: + + +[Creating a ConfluentKafkaContainer](../../modules/kafka/src/test/java/org/testcontainers/kafka/ConfluentKafkaContainerTest.java) inside_block:constructorWithVersion + + +## Options + +### Using Kraft mode + +!!! note + Only available for `org.testcontainers.containers.KafkaContainer` + +KRaft mode was declared production ready in 3.3.1 (confluentinc/cp-kafka:7.3.x) + + +[Kraft mode](../../modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java) inside_block:withKraftMode + + +See the [versions interoperability matrix](https://docs.confluent.io/platform/current/installation/versions-interoperability.html) for more details. + +### Register listeners + +There are scenarios where additional listeners are needed because the consumer/producer can be in another +container in the same network or a different process where the port to connect differs from the default exposed port. E.g [Toxiproxy](../../modules/toxiproxy/). + + +[Register additional listener](../../modules/kafka/src/test/java/org/testcontainers/kafka/KafkaContainerTest.java) inside_block:registerListener + + +Container defined in the same network: + + +[Create kcat container](../../modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java) inside_block:createKCatContainer + + +Client using the new registered listener: + + +[Produce/Consume via new listener](../../modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java) inside_block:produceConsumeMessage + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-kafka:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-kafka + {{latest_version}} + test + + ``` diff --git a/docs/modules/ldap.md b/docs/modules/ldap.md new file mode 100644 index 00000000000..c84469f22ae --- /dev/null +++ b/docs/modules/ldap.md @@ -0,0 +1,30 @@ +# LDAP + +Testcontainers module for [LLDAP](https://hub.docker.com/r/lldap/lldap). + +## LLdapContainer's usage examples + +You can start a LLDAP container instance from any Java application by using: + + +[LLDAP container](../../modules/ldap/src/test/java/org/testcontainers/ldap/LLdapContainerTest.java) inside_block:container + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-ldap:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-ldap + {{latest_version}} + test + + ``` diff --git a/docs/modules/localstack.md b/docs/modules/localstack.md new file mode 100644 index 00000000000..ca51979f5a0 --- /dev/null +++ b/docs/modules/localstack.md @@ -0,0 +1,44 @@ +# LocalStack Module + +Testcontainers module for [LocalStack](http://localstack.cloud/), 'a fully functional local AWS cloud stack', to develop and test your cloud and serverless apps without actually using the cloud. + +## Usage example + +You can start a LocalStack container instance from any Java application by using: + + +[Container creation](../../modules/localstack/src/test/java/org/testcontainers/localstack/LocalStackContainerTest.java) inside_block:container + + +Environment variables listed in the [LocalStack configuration documentation](https://docs.localstack.cloud/references/configuration/) may be used to customize LocalStack's configuration. +Use the `.withEnv(key, value)` method on `LocalStackContainer` to apply configuration settings. + +!!! note + Starting March 23, 2026, `localstack/localstack` requires authentication via a `LOCALSTACK_AUTH_TOKEN` environment variable. Without it, the container will fail to start. + + Use `.withEnv("LOCALSTACK_AUTH_TOKEN", System.getenv("LOCALSTACK_AUTH_TOKEN"))` to pass the token. + See the [LocalStack blog post](https://blog.localstack.cloud/localstack-single-image-next-steps/) for more details. + +## Creating a client using AWS SDK + + +[AWS SDK V2](../../modules/localstack/src/test/java/org/testcontainers/localstack/LocalStackContainerTest.java) inside_block:with_aws_sdk_v2 + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-localstack:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-localstack + {{latest_version}} + test + + ``` diff --git a/docs/modules/milvus.md b/docs/modules/milvus.md new file mode 100644 index 00000000000..96772560f80 --- /dev/null +++ b/docs/modules/milvus.md @@ -0,0 +1,36 @@ +# Milvus + +Testcontainers module for [Milvus](https://hub.docker.com/r/milvusdb/milvus). + +## Milvus's usage examples + +You can start a Milvus container instance from any Java application by using: + + +[Default config](../../modules/milvus/src/test/java/org/testcontainers/milvus/MilvusContainerTest.java) inside_block:milvusContainer + + +With external Etcd: + + +[External Etcd](../../modules/milvus/src/test/java/org/testcontainers/milvus/MilvusContainerTest.java) inside_block:externalEtcd + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-milvus:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-milvus + {{latest_version}} + test + + ``` diff --git a/docs/modules/minio.md b/docs/modules/minio.md new file mode 100644 index 00000000000..165f760425c --- /dev/null +++ b/docs/modules/minio.md @@ -0,0 +1,39 @@ +# MinIO Containers + +Testcontainers can be used to automatically instantiate and manage [MinIO](https://min.io) containers. + +## Usage example + +Create a `MinIOContainer` to use it in your tests: + +[Starting a MinIO container](../../modules/minio/src/test/java/org/testcontainers/containers/MinIOContainerTest.java) inside_block:minioContainer + + +The [MinIO Java client](https://min.io/docs/minio/linux/developers/java/API.html) can be configured with the container as such: + +[Configuring a MinIO client](../../modules/minio/src/test/java/org/testcontainers/containers/MinIOContainerTest.java) inside_block:configuringClient + + +If needed the username and password can be overridden as such: + +[Overriding a MinIO container](../../modules/minio/src/test/java/org/testcontainers/containers/MinIOContainerTest.java) inside_block:minioOverrides + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-minio:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-minio + {{latest_version}} + test + + ``` diff --git a/docs/modules/mockserver.md b/docs/modules/mockserver.md new file mode 100644 index 00000000000..e6a0ee91ece --- /dev/null +++ b/docs/modules/mockserver.md @@ -0,0 +1,38 @@ +# Mockserver Module + +Mock Server can be used to mock HTTP services by matching requests against user-defined expectations. + +## Usage example + +The following example shows how to start Mockserver. + + +[Creating a MockServer container](../../modules/mockserver/src/test/java/org/testcontainers/mockserver/MockServerContainerTest.java) inside_block:creatingProxy + + +And how to set a simple expectation using the Java MockServerClient. + + +[Setting a simple expectation](../../modules/mockserver/src/test/java/org/testcontainers/mockserver/MockServerContainerTest.java) inside_block:testSimpleExpectation + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-mockserver:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-mockserver + {{latest_version}} + test + + ``` + +Additionally, don't forget to add a [client dependency `org.mock-server:mockserver-client-java`](https://search.maven.org/search?q=mockserver-client-java) +to be able to set expectations, it's not provided by the testcontainers module. Client version should match to the version in a container tag. diff --git a/docs/modules/nginx.md b/docs/modules/nginx.md new file mode 100644 index 00000000000..6fcdbdaf13f --- /dev/null +++ b/docs/modules/nginx.md @@ -0,0 +1,41 @@ +# Nginx Module + +Nginx is a web server, reverse proxy and mail proxy and http cache. + +## Usage example + +The following example shows how to start Nginx. + + +[Creating a Nginx container](../../modules/nginx/src/test/java/org/testcontainers/nginx/NginxContainerTest.java) inside_block:creatingContainer + + +How to add custom content to the Nginx server. + + +[Creating the static content to serve](../../modules/nginx/src/test/java/org/testcontainers/nginx/NginxContainerTest.java) inside_block:addCustomContent + + +And how to query the Nginx server for the custom content added. + + +[Creating the static content to serve](../../modules/nginx/src/test/java/org/testcontainers/nginx/NginxContainerTest.java) inside_block:getFromNginxServer + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-nginx:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-nginx + {{latest_version}} + test + + ``` diff --git a/docs/modules/ollama.md b/docs/modules/ollama.md new file mode 100644 index 00000000000..56c1574d4db --- /dev/null +++ b/docs/modules/ollama.md @@ -0,0 +1,52 @@ +# Ollama + +Testcontainers module for [Ollama](https://hub.docker.com/r/ollama/ollama) . + +## Ollama's usage examples + +You can start an Ollama container instance from any Java application by using: + + +[Ollama container](../../modules/ollama/src/test/java/org/testcontainers/ollama/OllamaContainerTest.java) inside_block:container + + +### Pulling the model + +Testcontainers allows [executing commands in the container](../features/commands.md). So, pulling the model is as simple as: + + +[Pull model](../../modules/ollama/src/test/java/org/testcontainers/ollama/OllamaContainerTest.java) inside_block:pullModel + + +### Create a new Image + +In order to create a new image that contains the model, you can use the following code: + + +[Commit Image](../../modules/ollama/src/test/java/org/testcontainers/ollama/OllamaContainerTest.java) inside_block:commitToImage + + +And use the new image along with [Image name Substitution](../features/image_name_substitution.md#manual-substitution) + + +[Use new Image](../../modules/ollama/src/test/java/org/testcontainers/ollama/OllamaContainerTest.java) inside_block:substitute + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-ollama:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-ollama + {{latest_version}} + test + + ``` diff --git a/docs/modules/openfga.md b/docs/modules/openfga.md new file mode 100644 index 00000000000..4d916ce6aa6 --- /dev/null +++ b/docs/modules/openfga.md @@ -0,0 +1,30 @@ +# OpenFGA + +Testcontainers module for [OpenFGA](https://hub.docker.com/r/openfga/openfga). + +## OpenFGAContainer's usage examples + +You can start an OpenFGA container instance from any Java application by using: + + +[OpenFGA container](../../modules/openfga/src/test/java/org/testcontainers/openfga/OpenFGAContainerTest.java) inside_block:container + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-openfga:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-openfga + {{latest_version}} + test + + ``` diff --git a/docs/modules/pinecone.md b/docs/modules/pinecone.md new file mode 100644 index 00000000000..6bcea81ab39 --- /dev/null +++ b/docs/modules/pinecone.md @@ -0,0 +1,30 @@ +# Pinecone + +Testcontainers module for [Pinecone Local](https://github.com/orgs/pinecone-io/packages/container/package/pinecone-local). + +## PineconeLocalContainer's usage examples + +You can start a Pinecone container instance from any Java application by using: + + +[Pinecone container](../../modules/pinecone/src/test/java/org/testcontainers/pinecone/PineconeLocalContainerTest.java) inside_block:container + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" +```groovy +testImplementation "org.testcontainers:testcontainers-pinecone:{{latest_version}}" +``` + +=== "Maven" +```xml + + org.testcontainers + testcontainers-pinecone + {{latest_version}} + test + +``` diff --git a/docs/modules/pulsar.md b/docs/modules/pulsar.md new file mode 100644 index 00000000000..5bd33b0bc4c --- /dev/null +++ b/docs/modules/pulsar.md @@ -0,0 +1,65 @@ +# Apache Pulsar Module + +Testcontainers can be used to automatically create [Apache Pulsar](https://pulsar.apache.org) containers without external services. + +It's based on the official Apache Pulsar docker image, it is recommended to read the [official guide](https://pulsar.apache.org/docs/next/getting-started-docker/). + +## Example + +Create a `PulsarContainer` to use it in your tests: + + +[Create a Pulsar container](../../modules/pulsar/src/test/java/org/testcontainers/pulsar/PulsarContainerTest.java) inside_block:constructorWithVersion + + +Then you can retrieve the broker and the admin url: + + +[Get broker and admin urls](../../modules/pulsar/src/test/java/org/testcontainers/pulsar/PulsarContainerTest.java) inside_block:coordinates + + +## Options + +### Configuration +If you need to set Pulsar configuration variables you can use the native APIs and set each variable with `PULSAR_PREFIX_` as prefix. + +For example, if you want to enable `brokerDeduplicationEnabled`: + + +[Set configuration variables](../../modules/pulsar/src/test/java/org/testcontainers/pulsar/PulsarContainerTest.java) inside_block:constructorWithEnv + + +### Pulsar IO + +If you need to test Pulsar IO framework you can enable the Pulsar Functions Worker: + + +[Create a Pulsar container with functions worker](../../modules/pulsar/src/test/java/org/testcontainers/pulsar/PulsarContainerTest.java) inside_block:constructorWithFunctionsWorker + + +### Pulsar Transactions + +If you need to test Pulsar Transactions you can enable the transactions feature: + + +[Create a Pulsar container with transactions](../../modules/pulsar/src/test/java/org/testcontainers/pulsar/PulsarContainerTest.java) inside_block:constructorWithTransactions + + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-pulsar:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-pulsar + {{latest_version}} + test + + ``` diff --git a/docs/modules/qdrant.md b/docs/modules/qdrant.md new file mode 100644 index 00000000000..9431a340a9c --- /dev/null +++ b/docs/modules/qdrant.md @@ -0,0 +1,30 @@ +# Qdrant + +Testcontainers module for [Qdrant](https://registry.hub.docker.com/r/qdrant/qdrant) + +## Qdrant's usage examples + +You can start a Qdrant container instance from any Java application by using: + + +[Default QDrant container](../../modules/qdrant/src/test/java/org/testcontainers/qdrant/QdrantContainerTest.java) inside_block:qdrantContainer + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-qdrant:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-qdrant + {{latest_version}} + test + + ``` diff --git a/docs/modules/rabbitmq.md b/docs/modules/rabbitmq.md new file mode 100644 index 00000000000..5381e3cd910 --- /dev/null +++ b/docs/modules/rabbitmq.md @@ -0,0 +1,19 @@ +# RabbitMQ Module + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-rabbitmq:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-rabbitmq + {{latest_version}} + test + + ``` diff --git a/docs/modules/redpanda.md b/docs/modules/redpanda.md new file mode 100644 index 00000000000..429ae5a9768 --- /dev/null +++ b/docs/modules/redpanda.md @@ -0,0 +1,102 @@ +# Redpanda + +Testcontainers can be used to automatically instantiate and manage [Redpanda](https://redpanda.com/) containers. +More precisely Testcontainers uses the official Docker images for [Redpanda](https://hub.docker.com/r/redpandadata/redpanda) + +!!! note + This module uses features provided in `docker.redpanda.com/redpandadata/redpanda`. + +## Example + +Create a `Redpanda` to use it in your tests: + +[Creating a Redpanda](../../modules/redpanda/src/test/java/org/testcontainers/redpanda/RedpandaContainerTest.java) inside_block:constructorWithVersion + + +Now your tests or any other process running on your machine can get access to running Redpanda broker by using the following bootstrap server location: + + +[Bootstrap Servers](../../modules/redpanda/src/test/java/org/testcontainers/redpanda/RedpandaContainerTest.java) inside_block:getBootstrapServers + + +Redpanda also provides a schema registry implementation. Like the Redpanda broker, you can access by using the following schema registry location: + + +[Schema Registry](../../modules/redpanda/src/test/java/org/testcontainers/redpanda/RedpandaContainerTest.java) inside_block:getSchemaRegistryAddress + + +It is also possible to enable security capabilities of Redpanda by using: + + +[Enable security](../../modules/redpanda/src/test/java/org/testcontainers/redpanda/RedpandaContainerTest.java) inside_block:security + + +Superusers can be created by using: + + +[Register Superuser](../../modules/redpanda/src/test/java/org/testcontainers/redpanda/RedpandaContainerTest.java) inside_block:createSuperUser + + +Below is an example of how to create the `AdminClient`: + + +[Create Admin Client](../../modules/redpanda/src/test/java/org/testcontainers/redpanda/RedpandaContainerTest.java) inside_block:createAdminClient + + +There are scenarios where additional listeners are needed because the consumer/producer can be another +container in the same network or a different process where the port to connect differs from the default +exposed port `9092`. E.g [Toxiproxy](../modules/toxiproxy.md). + + +[Register additional listener](../../modules/redpanda/src/test/java/org/testcontainers/redpanda/RedpandaContainerTest.java) inside_block:registerListener + + +Container defined in the same network: + + +[Create kcat container](../../modules/redpanda/src/test/java/org/testcontainers/redpanda/RedpandaContainerTest.java) inside_block:createKCatContainer + + +Client using the new registered listener: + + +[Produce/Consume via new listener](../../modules/redpanda/src/test/java/org/testcontainers/redpanda/RedpandaContainerTest.java) inside_block:produceConsumeMessage + + +The following examples shows how to register a proxy as a new listener in `RedpandaContainer`: + +Use `SocatContainer` to create the proxy + + +[Create Proxy](../../modules/redpanda/src/test/java/org/testcontainers/redpanda/RedpandaContainerTest.java) inside_block:createProxy + + +Register the listener and advertised listener + + +[Register Listener](../../modules/redpanda/src/test/java/org/testcontainers/redpanda/RedpandaContainerTest.java) inside_block:registerListenerAndAdvertisedListener + + +Client using the new registered listener: + + +[Produce/Consume via new listener](../../modules/redpanda/src/test/java/org/testcontainers/redpanda/RedpandaContainerTest.java) inside_block:produceConsumeMessageFromProxy + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" +```groovy +testImplementation "org.testcontainers:testcontainers-redpanda:{{latest_version}}" +``` +=== "Maven" +```xml + + org.testcontainers + testcontainers-redpanda + {{latest_version}} + test + +``` diff --git a/docs/modules/solace.md b/docs/modules/solace.md new file mode 100644 index 00000000000..91956533906 --- /dev/null +++ b/docs/modules/solace.md @@ -0,0 +1,39 @@ +# Solace Container + +This module helps running [Solace PubSub+](https://solace.com/products/event-broker/software/) using Testcontainers. + +Note that it's based on the [official Docker image](https://hub.docker.com/r/solace/solace-pubsub-standard). + +## Usage example + +You can start a solace container instance from any Java application by using: + + +[Solace container setup with simple authentication](../../modules/solace/src/test/java/org/testcontainers/solace/SolaceContainerSMFTest.java) inside_block:solaceContainerSetup + + + +[Solace container setup with SSL](../../modules/solace/src/test/java/org/testcontainers/solace/SolaceContainerSMFTest.java) inside_block:solaceContainerUsageSSL + + + +[Using a Solace container](../../modules/solace/src/test/java/org/testcontainers/solace/SolaceContainerAMQPTest.java) inside_block:solaceContainerUsage + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-solace:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-solace + {{latest_version}} + test + + ``` diff --git a/docs/modules/solr.md b/docs/modules/solr.md new file mode 100644 index 00000000000..957615fcbf1 --- /dev/null +++ b/docs/modules/solr.md @@ -0,0 +1,31 @@ +# Solr Container + +This module helps running [solr](https://solr.apache.org/) using Testcontainers. + +Note that it's based on the [official Docker image](https://hub.docker.com/_/solr/). + +## Usage example + +You can start a solr container instance from any Java application by using: + + +[Using a Solr container](../../modules/solr/src/test/java/org/testcontainers/solr/SolrContainerTest.java) inside_block:solrContainerUsage + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-solr:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-solr + {{latest_version}} + test + + ``` diff --git a/docs/modules/toxiproxy.md b/docs/modules/toxiproxy.md new file mode 100644 index 00000000000..40f792911bb --- /dev/null +++ b/docs/modules/toxiproxy.md @@ -0,0 +1,88 @@ +# Toxiproxy Module + +Testcontainers module for Shopify's [Toxiproxy](https://github.com/Shopify/toxiproxy). +This TCP proxy can be used to simulate network failure conditions. + +You can simulate network failures: + +* between Java code and containers, ideal for testing resilience features of client code +* between containers, for testing resilience and emergent behaviour of multi-container systems +* if desired, between Java code/containers and external resources (non-Dockerized!), for scenarios where not all dependencies can be/have been dockerized + +Testcontainers Toxiproxy support allows resilience features to be easily verified as part of isolated dev/CI testing. This allows earlier testing of resilience features, and broader sets of failure conditions to be covered. + +## Usage example + +A Toxiproxy container can be placed in between test code and a container, or in between containers. +In either scenario, it is necessary to create a `ToxiproxyContainer` instance on the same Docker network, as follows: + + +[Creating a Toxiproxy container](../../modules/toxiproxy/src/test/java/org/testcontainers/toxiproxy/ToxiproxyContainerTest.java) inside_block:creatingProxy + + +Next, it is necessary to instruct Toxiproxy to start proxying connections. +Each `ToxiproxyContainer` can proxy to many target containers if necessary. + +We do this as follows: + + +[Starting proxying connections to a target container](../../modules/toxiproxy/src/test/java/org/testcontainers/toxiproxy/ToxiproxyContainerTest.java) inside_block:obtainProxyObject + + +To establish a connection from the test code (on the host machine) to the target container via Toxiproxy, we obtain **Toxiproxy's** proxy host IP and port: + + +[Obtaining proxied host and port](../../modules/toxiproxy/src/test/java/org/testcontainers/toxiproxy/ToxiproxyContainerTest.java) inside_block:obtainProxiedHostAndPortForHostMachine + + +Code under test should connect to this proxied host IP and port. + +!!! note + Currently, `ToxiProxyContainer` will reserve 31 ports, starting at 8666. + +Other containers should connect to this proxied host and port. + +Having done this, it is possible to trigger failure conditions ('Toxics') through the `proxy.toxics()` object: + +* `bandwidth` - Limit a connection to a maximum number of kilobytes per second. +* `latency` - Add a delay to all data going through the proxy. The delay is equal to `latency +/- jitter`. +* `slicer` - Slices TCP data up into small bits, optionally adding a delay between each sliced "packet". +* `slowClose` - Delay the TCP socket from closing until `delay` milliseconds has elapsed. +* `timeout` - Stops all data from getting through, and closes the connection after `timeout`. If `timeout` is `0`, the connection won't close, and data will be delayed until the toxic is removed. +* `limitData` - Closes connection when transmitted data exceeded limit. + +Please see the [Toxiproxy documentation](https://github.com/Shopify/toxiproxy#toxics) for full details on the available Toxics. + +As one example, we can introduce latency and random jitter to proxied connections as follows: + + +[Adding latency to a connection](../../modules/toxiproxy/src/test/java/org/testcontainers/toxiproxy/ToxiproxyContainerTest.java) inside_block:addingLatency + + +Additionally we can disable the proxy to simulate a complete interruption to the network connection: + + +[Cutting a connection](../../modules/toxiproxy/src/test/java/org/testcontainers/toxiproxy/ToxiproxyContainerTest.java) inside_block:disableProxy + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-toxiproxy:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-toxiproxy + {{latest_version}} + test + + ``` + +## Acknowledgements + +This module was inspired by a [hotels.com blog post](https://medium.com/hotels-com-technology/i-dont-know-about-resilience-testing-and-so-can-you-b3c59d80012d). diff --git a/docs/modules/typesense.md b/docs/modules/typesense.md new file mode 100644 index 00000000000..c73a640d881 --- /dev/null +++ b/docs/modules/typesense.md @@ -0,0 +1,30 @@ +# Typesense + +Testcontainers module for [Typesense](https://hub.docker.com/r/typesense/typesense). + +## TypesenseContainer's usage examples + +You can start a Typesense container instance from any Java application by using: + + +[Typesense container](../../modules/typesense/src/test/java/org/testcontainers/typesense/TypesenseContainerTest.java) inside_block:container + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-typesense:{{latest_version}}" + ``` + +=== "Maven" + ```xml + + org.testcontainers + testcontainers-typesense + {{latest_version}} + test + + ``` diff --git a/docs/modules/vault.md b/docs/modules/vault.md new file mode 100644 index 00000000000..161efa8ccb0 --- /dev/null +++ b/docs/modules/vault.md @@ -0,0 +1,52 @@ +# Hashicorp Vault Module + +Testcontainers module for [Vault](https://github.com/hashicorp/vault). Vault is a tool for managing secrets. More information on Vault [here](https://www.vaultproject.io/). + +## Usage example + +You can start a Vault container instance from any Java application by using: + + +[Starting a Vault container](../../modules/vault/src/test/java/org/testcontainers/vault/VaultContainerTest.java) inside_block:vaultContainer + + +Use CLI to read data from Vault container: + + +[Use CLI to read data](../../modules/vault/src/test/java/org/testcontainers/vault/VaultContainerTest.java) inside_block:readFirstSecretPathWithCli + + +Use Http API to read data from Vault container: + + +[Use Http API to read data](../../modules/vault/src/test/java/org/testcontainers/vault/VaultContainerTest.java) inside_block:readFirstSecretPathOverHttpApi + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-vault:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-vault + {{latest_version}} + test + + ``` + +## License + +See [LICENSE](https://raw.githubusercontent.com/testcontainers/testcontainers-java/main/modules/vault/LICENSE). + +## Copyright + +Copyright (c) 2017 Capital One Services, LLC and other authors. + +See [AUTHORS](https://raw.githubusercontent.com/testcontainers/testcontainers-java/main/modules/vault/AUTHORS) for contributors. + diff --git a/docs/modules/weaviate.md b/docs/modules/weaviate.md new file mode 100644 index 00000000000..76146ab5ee9 --- /dev/null +++ b/docs/modules/weaviate.md @@ -0,0 +1,30 @@ +# Weaviate + +Testcontainers module for [Weaviate](https://hub.docker.com/r/semitechnologies/weaviate) + +## WeaviateContainer's usage examples + +You can start a Weaviate container instance from any Java application by using: + + +[Default Weaviate container](../../modules/weaviate/src/test/java/org/testcontainers/weaviate/WeaviateContainerTest.java) inside_block:container + + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" +```groovy +testImplementation "org.testcontainers:testcontainers-weaviate:{{latest_version}}" +``` + +=== "Maven" +```xml + +org.testcontainers +testcontainers-weaviate +{{latest_version}} +test + +``` diff --git a/docs/modules/webdriver_containers.md b/docs/modules/webdriver_containers.md new file mode 100644 index 00000000000..8101f489e3a --- /dev/null +++ b/docs/modules/webdriver_containers.md @@ -0,0 +1,122 @@ +# Webdriver Containers + +Testcontainers can be used to automatically instantiate and manage containers that include web browsers, such as those +from SeleniumHQ's [docker-selenium](https://github.com/SeleniumHQ/docker-selenium) project. + +## Benefits + +* Fully compatible with Selenium 3 & 4 tests for Chrome and Firefox and Selenium 4 tests for Edge, by providing a `RemoteWebDriver` instance +* No need to have specific web browsers, or even a desktop environment, installed on test servers. The only dependency + is a working Docker installation and your Java JUnit test suite. +* Browsers are always launched from a fixed, clean image. This means no configuration drift from user changes or + automatic browser upgrades. +* Compatibility between browser version and the Selenium API is assured: a compatible version of the browser docker + images will be automatically selected to match the version of `selenium-api-*.jar` on the classpath +* Additionally the use of a clean browser prevents leakage of cookies, cached data or other state between tests. +* **VNC screen recording**: Testcontainers can automatically record video of test runs (optionally capturing just + failing tests) + +Creation of browser containers is fast, so it's actually quite feasible to have a totally fresh browser instance for +every test. + +## Example + +The following field in your JUnit UI test class will prepare a container running Chrome: + +[Chrome](../../modules/selenium/src/test/java/org/testcontainers/selenium/ChromeWebDriverContainerTest.java) inside_block:junitRule + + + +Now, instead of instantiating an instance of WebDriver directly, use the following to obtain an instance inside your +test methods: + +[RemoteWebDriver](../../modules/selenium/src/test/java/org/testcontainers/selenium/LocalServerWebDriverContainerTest.java) inside_block:getWebDriver + + +You can then use this driver instance like a regular WebDriver. + +Note that, if you want to test a **web application running on the host machine** (the machine the JUnit tests are +running on - which is quite likely), you'll need to use [the host exposing](../features/networking.md#exposing-host-ports-to-the-container) feature of Testcontainers, e.g.: + +[Open Web Page](../../modules/selenium/src/test/java/org/testcontainers/selenium/LocalServerWebDriverContainerTest.java) inside_block:getPage + + + +## Options + +### Other browsers + +At the moment, Chrome, Firefox and Edge are supported. To switch, simply change the first parameter to the rule constructor: + +[Chrome](../../modules/selenium/src/test/java/org/testcontainers/selenium/ChromeWebDriverContainerTest.java) inside_block:junitRule +[Firefox](../../modules/selenium/src/test/java/org/testcontainers/selenium/FirefoxWebDriverContainerTest.java) inside_block:junitRule +[Edge](../../modules/selenium/src/test/java/org/testcontainers/selenium/EdgeWebDriverContainerTest.java) inside_block:junitRule + + +### Recording videos + +By default, no videos will be recorded. However, you can instruct Testcontainers to capture videos for all tests or +just for failing tests. + + +[Record all Tests](../../modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java) inside_block:recordAll +[Record failing Tests](../../modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java) inside_block:recordFailing + + +Note that the second parameter of `withRecordingMode` should be a directory where recordings can be saved. + +By default, the video will be recorded in [FLV](https://en.wikipedia.org/wiki/Flash_Video) format, but you can specify it explicitly or change it to [MP4](https://en.wikipedia.org/wiki/MPEG-4_Part_14) using `withRecordingMode` method with `VncRecordingFormat` option: + + +[Video Format in MP4](../../modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java) inside_block:recordMp4 +[Video Format in FLV](../../modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java) inside_block:recordFlv + + +If you would like to customise the file name of the recording, or provide a different directory at runtime based on the description of the test and/or its success or failure, you may provide a custom recording file factory as follows: + +[CustomRecordingFileFactory](../../modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java) inside_block:withRecordingFileFactory + + + +Note the factory must implement `org.testcontainers.containers.RecordingFileFactory`. + +## More examples + +A few different examples are shown in [ChromeWebDriverContainerTest.java](https://github.com/testcontainers/testcontainers-java/blob/main/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeWebDriverContainerTest.java). + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-selenium:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-selenium + {{latest_version}} + test + + ``` + +!!! hint + Adding this Testcontainers library JAR will not automatically add a Selenium Webdriver JAR to your project. You should ensure that your project also has suitable Selenium dependencies in place, for example: + + === "Gradle" + ```groovy + compile "org.seleniumhq.selenium:selenium-remote-driver:3.141.59" + ``` + + === "Maven" + ```xml + + org.seleniumhq.selenium + selenium-remote-driver + 3.141.59 + + ``` + + Testcontainers will try and match the version of the Dockerized browser to whichever version of Selenium is found on the classpath diff --git a/docs/modules_logos/hivemq-module.png b/docs/modules_logos/hivemq-module.png new file mode 100644 index 00000000000..55c0ee1644a Binary files /dev/null and b/docs/modules_logos/hivemq-module.png differ diff --git a/docs/quickstart/junit_4_quickstart.md b/docs/quickstart/junit_4_quickstart.md new file mode 100644 index 00000000000..57946c1fb74 --- /dev/null +++ b/docs/quickstart/junit_4_quickstart.md @@ -0,0 +1,83 @@ +# JUnit 4 Quickstart + +It's easy to add Testcontainers to your project - let's walk through a quick example to see how. + +Let's imagine we have a simple program that has a dependency on Redis, and we want to add some tests for it. +In our imaginary program, there is a `RedisBackedCache` class which stores data in Redis. + +You can see an example test that could have been written for it (without using Testcontainers): + + +[Pre-Testcontainers test code](../examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java) block:RedisBackedCacheIntTestStep0 + + +Notice that the existing test has a problem - it's relying on a local installation of Redis, which is a red flag for test reliability. +This may work if we were sure that every developer and CI machine had Redis installed, but would fail otherwise. +We might also have problems if we attempted to run tests in parallel, such as state bleeding between tests, or port clashes. + +Let's start from here, and see how to improve the test with Testcontainers: + +## 1. Add Testcontainers as a test-scoped dependency + +First, add Testcontainers as a dependency as follows: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers + {{latest_version}} + test + + ``` + +## 2. Get Testcontainers to run a Redis container during our tests + +Simply add the following to the body of our test class: + + +[JUnit 4 Rule](../examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java) inside_block:rule + + +The `@Rule` annotation tells JUnit to notify this field about various events in the test lifecycle. +In this case, our rule object is a Testcontainers `GenericContainer`, configured to use a specific Redis image from Docker Hub, and configured to expose a port. + +If we run our test as-is, then regardless of the actual test outcome, we'll see logs showing us that Testcontainers: + +* was activated before our test method ran +* discovered and quickly tested our local Docker setup +* pulled the image if necessary +* started a new container and waited for it to be ready +* shut down and deleted the container after the test + +## 3. Make sure our code can talk to the container + +Before Testcontainers, we might have hardcoded an address like `localhost:6379` into our tests. + +Testcontainers uses *randomized ports* for each container it starts, but makes it easy to obtain the actual port at runtime. +We can do this in our test `setUp` method, to set up our component under test: + + +[Obtaining a mapped port](../examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java) inside_block:setUp + + +!!! tip + Notice that we also ask Testcontainers for the container's actual address with `redis.getHost();`, + rather than hard-coding `localhost`. `localhost` may work in some environments but not others - for example it may + not work on your current or future CI environment. As such, **avoid hard-coding** the address, and use + `getHost()` instead. + +## 4. Run the tests! + +That's it! + +Let's look at our complete test class to see how little we had to add to get up and running with Testcontainers: + + +[RedisBackedCacheIntTest](../examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java) block:RedisBackedCacheIntTest + + diff --git a/docs/quickstart/junit_5_quickstart.md b/docs/quickstart/junit_5_quickstart.md new file mode 100644 index 00000000000..effd17dd2d9 --- /dev/null +++ b/docs/quickstart/junit_5_quickstart.md @@ -0,0 +1,106 @@ +# JUnit 5 Quickstart + +It's easy to add Testcontainers to your project - let's walk through a quick example to see how. + +Let's imagine we have a simple program that has a dependency on Redis, and we want to add some tests for it. +In our imaginary program, there is a `RedisBackedCache` class which stores data in Redis. + +You can see an example test that could have been written for it (without using Testcontainers): + + +[Pre-Testcontainers test code](../examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java) block:RedisBackedCacheIntTestStep0 + + +Notice that the existing test has a problem - it's relying on a local installation of Redis, which is a red flag for test reliability. +This may work if we were sure that every developer and CI machine had Redis installed, but would fail otherwise. +We might also have problems if we attempted to run tests in parallel, such as state bleeding between tests, or port clashes. + +Let's start from here, and see how to improve the test with Testcontainers: + +## 1. Add Testcontainers as a test-scoped dependency + +First, add Testcontainers as a dependency as follows: + +=== "Gradle" + ```groovy + testImplementation "org.junit.jupiter:junit-jupiter:5.8.1" + testImplementation "org.testcontainers:testcontainers:{{latest_version}}" + testImplementation "org.testcontainers:testcontainers-junit-jupiter:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.junit.jupiter + junit-jupiter + 5.8.1 + test + + + org.testcontainers + testcontainers + {{latest_version}} + test + + + org.testcontainers + testcontainers-junit-jupiter + {{latest_version}} + test + + ``` + +## 2. Get Testcontainers to run a Redis container during our tests + +First, you'll need to annotate the test class with `@Testcontainers`. Furthermore, add the following to the body of our test class: + + +[JUnit 5 Rule](../examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java) inside_block:container + + +The `@Container` annotation tells JUnit to notify this field about various events in the test lifecycle. +In this case, our rule object is a Testcontainers `GenericContainer`, configured to use a specific Redis image from Docker Hub, and configured to expose a port. + +If we run our test as-is, then regardless of the actual test outcome, we'll see logs showing us that Testcontainers: + +* was activated before our test method ran +* discovered and quickly tested our local Docker setup +* pulled the image if necessary +* started a new container and waited for it to be ready +* shut down and deleted the container after the test + +## 3. Make sure our code can talk to the container + +Before Testcontainers, we might have hardcoded an address like `localhost:6379` into our tests. + +Testcontainers uses *randomized ports* for each container it starts, but makes it easy to obtain the actual port at runtime. +We can do this in our test `setUp` method, to set up our component under test: + + +[Obtaining a mapped port](../examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java) inside_block:setUp + + +!!! tip + Notice that we also ask Testcontainers for the container's actual address with `redis.getHost();`, + rather than hard-coding `localhost`. `localhost` may work in some environments but not others - for example it may + not work on your current or future CI environment. As such, **avoid hard-coding** the address, and use + `getHost()` instead. + +## 4. Additional attributes + +Additional attributes are available for the `@Testcontainers` annotation. +Those attributes can be helpful when: + +* Tests should be skipped instead of failing because Docker is unavailable in the +current environment. Set `disabledWithoutDocker` to `true`. +* Enable parallel container initialization instead of sequential (by default). Set `parallel` to `true`. + +## 5. Run the tests! + +That's it! + +Let's look at our complete test class to see how little we had to add to get up and running with Testcontainers: + + +[RedisBackedCacheIntTest](../examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java) inside_block:class + + diff --git a/docs/quickstart/spock_quickstart.md b/docs/quickstart/spock_quickstart.md new file mode 100644 index 00000000000..c9c78bea5ea --- /dev/null +++ b/docs/quickstart/spock_quickstart.md @@ -0,0 +1,89 @@ +# Spock Quickstart + +It's easy to add Testcontainers to your project - let's walk through a quick example to see how. + +Let's imagine we have a simple program that has a dependency on Redis, and we want to add some tests for it. +In our imaginary program, there is a `RedisBackedCache` class which stores data in Redis. + +You can see an example test that could have been written for it (without using Testcontainers): + + +[Pre-Testcontainers test code](../examples/spock/redis/src/test/groovy/quickstart/RedisBackedCacheIntTestStep0.groovy) block:RedisBackedCacheIntTestStep0 + + +Notice that the existing test has a problem - it's relying on a local installation of Redis, which is a red flag for test reliability. +This may work if we were sure that every developer and CI machine had Redis installed, but would fail otherwise. +We might also have problems if we attempted to run tests in parallel, such as state bleeding between tests, or port clashes. + +Let's start from here, and see how to improve the test with Testcontainers: + +## 1. Add Testcontainers as a test-scoped dependency + +First, add Testcontainers as a dependency as follows: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-spock:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-spock + {{latest_version}} + test + + ``` + +## 2. Get Testcontainers to run a Redis container during our tests + +Annotate the Spock specification class with the Testcontainers extension: + +=== "Spock Testcontainers annotation" + ```groovy + @org.testcontainers.spock.Testcontainers + class RedisBackedCacheIntTest extends Specification { + ``` + +And add the following field to the body of our test class: + + +[Spock Testcontainers init](../examples/spock/redis/src/test/groovy/quickstart/RedisBackedCacheIntTest.groovy) inside_block:init + + +This tells Spock to start a Testcontainers `GenericContainer`, configured to use a specific Redis image from Docker Hub, and configured to expose a port. + +If we run our test as-is, then regardless of the actual test outcome, we'll see logs showing us that Testcontainers: + +* was activated before our test method ran +* discovered and quickly tested our local Docker setup +* pulled the image if necessary +* started a new container and waited for it to be ready +* shut down and deleted the container after the test + +## 3. Make sure our code can talk to the container + +Before Testcontainers, we might have hardcoded an address like `localhost:6379` into our tests. + +Testcontainers uses *randomized ports* for each container it starts, but makes it easy to obtain the actual port at runtime. +We can do this in our test `setup` method, to set up our component under test: + + +[Obtaining a mapped port](../examples/spock/redis/src/test/groovy/quickstart/RedisBackedCacheIntTest.groovy) inside_block:setup + + +!!! tip + Notice that we also ask Testcontainers for the container's actual address with `redis.containerIpAddress`, + rather than hard-coding `localhost`. `localhost` may work in some environments but not others - for example it may + not work on your current or future CI environment. As such, **avoid hard-coding** the address, and use + `containerIpAddress` instead. + +## 4. Run the tests! + +That's it! + +Let's look at our complete test class to see how little we had to add to get up and running with Testcontainers: + + +[RedisBackedCacheIntTest](../examples/spock/redis/src/test/groovy/quickstart/RedisBackedCacheIntTest.groovy) block:complete + diff --git a/docs/sponsor_logos/backbase.png b/docs/sponsor_logos/backbase.png new file mode 100644 index 00000000000..22acf3aac8e Binary files /dev/null and b/docs/sponsor_logos/backbase.png differ diff --git a/docs/sponsor_logos/cirrus_labs.jpg b/docs/sponsor_logos/cirrus_labs.jpg new file mode 100644 index 00000000000..3bec84f0a22 Binary files /dev/null and b/docs/sponsor_logos/cirrus_labs.jpg differ diff --git a/docs/sponsor_logos/elastic.png b/docs/sponsor_logos/elastic.png new file mode 100644 index 00000000000..393e6711c45 Binary files /dev/null and b/docs/sponsor_logos/elastic.png differ diff --git a/docs/sponsor_logos/jooq.jpg b/docs/sponsor_logos/jooq.jpg new file mode 100644 index 00000000000..7a64f773d61 Binary files /dev/null and b/docs/sponsor_logos/jooq.jpg differ diff --git a/docs/sponsor_logos/red_hat.png b/docs/sponsor_logos/red_hat.png new file mode 100644 index 00000000000..b8c28b5d7b0 Binary files /dev/null and b/docs/sponsor_logos/red_hat.png differ diff --git a/docs/sponsor_logos/spotify.png b/docs/sponsor_logos/spotify.png new file mode 100644 index 00000000000..41f149f713b Binary files /dev/null and b/docs/sponsor_logos/spotify.png differ diff --git a/docs/sponsor_logos/vivy.png b/docs/sponsor_logos/vivy.png new file mode 100644 index 00000000000..2faa4742dd1 Binary files /dev/null and b/docs/sponsor_logos/vivy.png differ diff --git a/docs/styles/website.css b/docs/styles/website.css deleted file mode 100644 index 344be3c56c3..00000000000 --- a/docs/styles/website.css +++ /dev/null @@ -1,16 +0,0 @@ -.summary a.custom-link[href^="https://www.gitbook.com/"] { - display: none !important; -} - -.summary .divider:first-of-type { - display: none !important; -} - -.book .book-summary .exc-trigger:before { - content: "\f105"; -} - -.book .book-summary .expanded > a .exc-trigger:before, -.book .book-summary .expanded > span .exc-trigger:before { - content: "\f107"; -} diff --git a/docs/supported_docker_environment/continuous_integration/aws_codebuild.md b/docs/supported_docker_environment/continuous_integration/aws_codebuild.md new file mode 100644 index 00000000000..c28854d9e0e --- /dev/null +++ b/docs/supported_docker_environment/continuous_integration/aws_codebuild.md @@ -0,0 +1,18 @@ +# AWS CodeBuild + +To enable access to Docker in AWS CodeBuild, go to `Privileged` section and check +`Enable this flag if you want to build Docker images or want your builds to get elevated privileges`. + +This is a sample `buildspec.yml` config: + +```yaml +version: 0.2 + +phases: + install: + runtime-versions: + java: corretto17 + build: + commands: + - ./mvnw test +``` diff --git a/docs/supported_docker_environment/continuous_integration/bitbucket_pipelines.md b/docs/supported_docker_environment/continuous_integration/bitbucket_pipelines.md new file mode 100644 index 00000000000..0fd76448ffc --- /dev/null +++ b/docs/supported_docker_environment/continuous_integration/bitbucket_pipelines.md @@ -0,0 +1,26 @@ +# Bitbucket Pipelines + +To enable access to Docker in Bitbucket Pipelines, you need to add `docker` as a service on the step. + +Furthermore, Ryuk needs to be turned off since Bitbucket Pipelines does not allow starting privileged containers (see [Disabling Ryuk](../../features/configuration.md#disabling-ryuk)). This can either be done by setting a repository variable in Bitbucket's project settings or by explicitly exporting the variable on a step. + +In some cases the memory available to Docker needs to be increased. + +Here is a sample Bitbucket Pipeline configuration that does a checkout of a project and runs maven: + +```yml +image: maven:3.6.1 + +pipelines: + default: + - step: + script: + - export TESTCONTAINERS_RYUK_DISABLED=true + - mvn clean install + services: + - docker +definitions: + services: + docker: + memory: 2048 +``` diff --git a/docs/supported_docker_environment/continuous_integration/circle_ci.md b/docs/supported_docker_environment/continuous_integration/circle_ci.md new file mode 100644 index 00000000000..936192cc799 --- /dev/null +++ b/docs/supported_docker_environment/continuous_integration/circle_ci.md @@ -0,0 +1,18 @@ +# CircleCI (Cloud, Server v2.x, and Server v3.x) + +Your CircleCI configuration should use a dedicated VM for Testcontainers to work. You can achieve this by specifying the +executor type in your `.circleci/config.yml` to be `machine` instead of the default `docker` executor (see [Choosing an Executor Type](https://circleci.com/docs/executor-intro) for more info). + +Here is a sample CircleCI configuration that does a checkout of a project and runs Maven: + +```yml +jobs: + build: + # Check https://circleci.com/docs/executor-intro#linux-vm for more details + machine: true + steps: + - checkout + - run: mvn -B clean install +``` + +You can learn more about the best practices of using Testcontainers together with CircleCI in [this article](https://www.atomicjar.com/2022/12/testcontainers-with-circleci/). diff --git a/docs/supported_docker_environment/continuous_integration/concourse_ci.md b/docs/supported_docker_environment/continuous_integration/concourse_ci.md new file mode 100644 index 00000000000..3ca1ee897e8 --- /dev/null +++ b/docs/supported_docker_environment/continuous_integration/concourse_ci.md @@ -0,0 +1,46 @@ +# Concourse CI + +This is an example to run Testcontainers tests on [Concourse CI](https://concourse-ci.org/). + +A possible `pipeline.yml` config looks like this: + +```yaml +resources: +- name: repo + type: git + source: + uri: https://github.com/testcontainers/testcontainers-java-repro.git + +jobs: +- name: testcontainers-job + plan: + # Add a get step referencing the resource + - get: repo + - task: testcontainers-task + privileged: true + config: + platform: linux + image_resource: + type: docker-image + source: + repository: amidos/dcind + tag: 2.1.0 + inputs: + - name: repo + run: + path: /bin/sh + args: + - -c + - | + source /docker-lib.sh + start_docker + + cd repo + docker run -it --rm -v "$PWD:$PWD" -w "$PWD" -v /var/run/docker.sock:/var/run/docker.sock eclipse-temurin:17.0.5_8-jdk-alpine ./mvnw clean package +``` + +```bash +fly -t tutorial set-pipeline -p testcontainers-pipeline -c pipeline.yml +fly -t tutorial unpause-pipeline -p testcontainers-pipeline +fly -t tutorial trigger-job --job testcontainers-pipeline/testcontainers-job --watch +``` diff --git a/docs/usage/inside_docker.md b/docs/supported_docker_environment/continuous_integration/dind_patterns.md similarity index 57% rename from docs/usage/inside_docker.md rename to docs/supported_docker_environment/continuous_integration/dind_patterns.md index f4773dcb147..2ea0627216e 100644 --- a/docs/usage/inside_docker.md +++ b/docs/supported_docker_environment/continuous_integration/dind_patterns.md @@ -1,18 +1,18 @@ -# Running inside a Docker container +# Patterns for running tests inside a Docker container -> Since 1.1.8 +## 'Docker wormhole' pattern - Sibling docker containers Testcontainers itself can be used from inside a container. This is very useful for different CI scenarios like running everything in containers on Jenkins, or Docker-based CI tools such as Drone. -TestContainers will automatically detect if it's inside a container and instead of "localhost" will use the default gateway's IP. +Testcontainers will automatically detect if it's inside a container and instead of "localhost" will use the default gateway's IP. -However, additional configuration is required if you use [volume mapping](options.md#volume-mapping). The following points need to be considered: +However, additional configuration is required if you use [volume mapping](../../features/files.md). The following points need to be considered: * The docker socket must be available via a volume mount * The 'local' source code directory must be volume mounted *at the same path* inside the container that Testcontainers runs in, so that Testcontainers is able to set up the correct volume mounts for the containers it spawns. -## Docker-only example +### Docker-only example If you run the tests with just `docker run ...` then make sure you add `-v $PWD:$PWD -w $PWD -v /var/run/docker.sock:/var/run/docker.sock` to the command, so it will look like this: ```bash $ tree . @@ -21,18 +21,25 @@ $ tree . └── src └── test └── java - └── MyTestWithTestContainers.java + └── MyTestWithTestcontainers.java $ docker run -it --rm -v $PWD:$PWD -w $PWD -v /var/run/docker.sock:/var/run/docker.sock maven:3 mvn test ``` Where: + * `-v $PWD:$PWD` will add your current directory as a volume inside the container * `-w $PWD` will set the current directory to this volume * `-v /var/run/docker.sock:/var/run/docker.sock` will map the Docker socket -## Docker Compose example -The same can be achived with Docker Compose: + +!!! note + If you are using Docker Desktop, you need to configure the `TESTCONTAINERS_HOST_OVERRIDE` environment variable to use the special DNS name + `host.docker.internal` for accessing the host from within a container, which is provided by Docker Desktop: + `-e TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal` + +### Docker Compose example +The same can be achieved with Docker Compose: ```yaml tests: image: maven:3 @@ -47,3 +54,10 @@ tests: - ~/.m2:/root/.m2 command: mvn test ``` + +## Docker-in-Docker + +While Docker-in-Docker (DinD) is generally considered an instrument of last resort, it is necessary for some CI environments. + +[Drone CI](./drone.md) is one such example. Testcontainers has a Docker-in-Docker plugin (build image) for use with Drone, +which could be used as inspiration for setting up other similar testing using DinD. diff --git a/docs/supported_docker_environment/continuous_integration/drone.md b/docs/supported_docker_environment/continuous_integration/drone.md new file mode 100644 index 00000000000..f4a1dd2bc6a --- /dev/null +++ b/docs/supported_docker_environment/continuous_integration/drone.md @@ -0,0 +1,6 @@ +# Drone CI + +Drone CI 0.8 is supported via the use of a general purpose Docker-in-Docker plugin. + +Please see [testcontainers/dind-drone-plugin](https://github.com/testcontainers/dind-drone-plugin) for further details and usage instructions. + diff --git a/docs/supported_docker_environment/continuous_integration/gitlab_ci.md b/docs/supported_docker_environment/continuous_integration/gitlab_ci.md new file mode 100644 index 00000000000..ccc73f58078 --- /dev/null +++ b/docs/supported_docker_environment/continuous_integration/gitlab_ci.md @@ -0,0 +1,60 @@ +# GitLab CI + +## Example using Docker socket +This applies if you have your own GitlabCI runner installed, use the Docker executor and you have `/var/run/docker.sock` mounted in the runner configuration. + +See below for an example runner configuration: +```toml +[[runners]] + name = "MACHINE_NAME" + url = "https://gitlab.com/" + token = "GENERATED_GITLAB_RUNNER_TOKEN" + executor = "docker" + [runners.docker] + tls_verify = false + image = "docker:latest" + privileged = false + disable_entrypoint_overwrite = false + oom_kill_disable = false + disable_cache = false + volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"] + shm_size = 0 +``` + +Please also include the following in your GitlabCI pipeline definitions (`.gitlab-ci.yml`) that use Testcontainers: +```yml +variables: + TESTCONTAINERS_HOST_OVERRIDE: "" +``` + +The environment variable `TESTCONTAINERS_HOST_OVERRIDE` needs to be configured, otherwise, a wrong IP address would be used to resolve the Docker host, which will likely lead to failing tests. For Windows and MacOS, use `host.docker.internal`. + +## Example using DinD (Docker-in-Docker) + +In order to use Testcontainers in a Gitlab CI pipeline, you need to run the job as a Docker container (see [Patterns for running inside Docker](dind_patterns.md)). +So edit your `.gitlab-ci.yml` to include the [Docker-In-Docker service](https://docs.gitlab.com/ee/ci/docker/using_docker_build.html#use-docker-in-docker-workflow-with-docker-executor) (`docker:dind`) and set the `DOCKER_HOST` variable to `tcp://docker:2375` and `DOCKER_TLS_CERTDIR` to empty string. + +Caveat: Current docker releases (verified for 20.10.9) intentionally delay the startup, if the docker api is bound to a network address but not TLS protected. To avoid this delay, the docker process needs to be started with the argument `--tls=false`. Otherwise jobs which access the docker api at the very beginning might fail. + +Here is a sample `.gitlab-ci.yml` that executes test with gradle: + +```yml +# DinD service is required for Testcontainers +services: + - name: docker:dind + # explicitly disable tls to avoid docker startup interruption + command: ["--tls=false"] + +variables: + # Instruct Testcontainers to use the daemon of DinD, use port 2375 for non-tls connections. + DOCKER_HOST: "tcp://docker:2375" + # Instruct Docker not to start over TLS. + DOCKER_TLS_CERTDIR: "" + # Improve performance with overlayfs. + DOCKER_DRIVER: overlay2 + +test: + image: gradle:5.0 + stage: test + script: ./gradlew test +``` diff --git a/docs/supported_docker_environment/continuous_integration/tekton.md b/docs/supported_docker_environment/continuous_integration/tekton.md new file mode 100644 index 00000000000..82c9f1d9a71 --- /dev/null +++ b/docs/supported_docker_environment/continuous_integration/tekton.md @@ -0,0 +1,94 @@ +# Tekton + +To enable access to Docker in Tekton, a dind sidecar needs to be added. An example of it can be found +[here](https://github.com/tektoncd/pipeline/blob/main/examples/v1beta1/taskruns/dind-sidecar.yaml) + +This is an example + +```yaml +apiVersion: tekton.dev/v1beta1 +kind: Task +metadata: + name: run-tests + description: Run Tests +spec: + workspaces: + - name: source + steps: + - name: read + image: eclipse-temurin:17.0.3_7-jdk-alpine + workingDir: $(workspaces.source.path) + script: ./mvnw test + volumeMounts: + - mountPath: /var/run/ + name: dind-socket + sidecars: + - image: docker:20.10-dind + name: docker + securityContext: + privileged: true + volumeMounts: + - mountPath: /var/lib/docker + name: dind-storage + - mountPath: /var/run/ + name: dind-socket + volumes: + - name: dind-storage + emptyDir: { } + - name: dind-socket + emptyDir: { } +--- +apiVersion: tekton.dev/v1beta1 +kind: Pipeline +metadata: + name: testcontainers-demo +spec: + description: | + This pipeline clones a git repo, run testcontainers. + params: + - name: repo-url + type: string + description: The git repo URL to clone from. + workspaces: + - name: shared-data + description: | + This workspace contains the cloned repo files, so they can be read by the + next task. + tasks: + - name: fetch-source + taskRef: + name: git-clone + workspaces: + - name: output + workspace: shared-data + params: + - name: url + value: $(params.repo-url) + - name: run-tests + runAfter: ["fetch-source"] + taskRef: + name: run-tests + workspaces: + - name: source + workspace: shared-data +--- +apiVersion: tekton.dev/v1beta1 +kind: PipelineRun +metadata: + name: testcontainers-demo-run +spec: + pipelineRef: + name: testcontainers-demo + workspaces: + - name: shared-data + volumeClaimTemplate: + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + params: + - name: repo-url + value: https://github.com/testcontainers/testcontainers-java-repro.git +``` diff --git a/docs/supported_docker_environment/continuous_integration/travis.md b/docs/supported_docker_environment/continuous_integration/travis.md new file mode 100644 index 00000000000..f4ea31abaea --- /dev/null +++ b/docs/supported_docker_environment/continuous_integration/travis.md @@ -0,0 +1,15 @@ +# Travis + +To run Testcontainers on TravisCI, docker needs to be installed. The configuration below +is the minimal required config. + +```yaml +language: java +jdk: +- openjdk8 + +services: +- docker + +script: ./mvnw verify +``` diff --git a/docs/supported_docker_environment/image_registry_rate_limiting.md b/docs/supported_docker_environment/image_registry_rate_limiting.md new file mode 100644 index 00000000000..a136a12b25e --- /dev/null +++ b/docs/supported_docker_environment/image_registry_rate_limiting.md @@ -0,0 +1,19 @@ +# Image Registry rate limiting + +As of November 2020 Docker Hub pulls are rate limited. +As Testcontainers uses Docker Hub for standard images, some users may hit these rate limits and should mitigate accordingly. + +Suggested mitigations are noted in [this issue](https://github.com/testcontainers/testcontainers-java/issues/3099) at present. + +## Which images are used by Testcontainers? + +As of the current version of Testcontainers ({{latest_version}}): + +* every image directly used by your tests +* images pulled by Testcontainers itself to support functionality: + * [`testcontainers/ryuk`](https://hub.docker.com/r/testcontainers/ryuk) - performs fail-safe cleanup of containers, and always required (unless [Ryuk is disabled](../features/configuration.md#disabling-ryuk)) + * [`alpine`](https://hub.docker.com/r/_/alpine) - used to check whether images can be pulled at startup, and always required (unless [startup checks are disabled](../features/configuration.md#disabling-the-startup-checks)) + * [`testcontainers/sshd`](https://hub.docker.com/r/testcontainers/sshd) - required if [exposing host ports to containers](../features/networking.md#exposing-host-ports-to-the-container) + * [`testcontainers/vnc-recorder`](https://hub.docker.com/r/testcontainers/vnc-recorder) - required if using [Webdriver containers](../modules/webdriver_containers.md) and using the screen recording feature + * [`docker/compose`](https://hub.docker.com/r/docker/compose) - required if using [Docker Compose](../modules/docker_compose.md) + * [`alpine/socat`](https://hub.docker.com/r/alpine/socat) - required if using [Docker Compose](../modules/docker_compose.md) diff --git a/docs/supported_docker_environment/index.md b/docs/supported_docker_environment/index.md new file mode 100644 index 00000000000..0bdac109245 --- /dev/null +++ b/docs/supported_docker_environment/index.md @@ -0,0 +1,112 @@ +# General Container runtime requirements + +## Overview + +To run Testcontainers-based tests, +you need a Docker-API compatible container runtime, +such as using [Testcontainers Cloud](https://www.testcontainers.cloud/) or installing Docker locally. +During development, Testcontainers is actively tested against recent versions of Docker on Linux, +as well as against Docker Desktop on Mac and Windows. +These Docker environments are automatically detected and used by Testcontainers without any additional configuration being necessary. + +It is possible to configure Testcontainers to work with alternative container runtimes. +Making use of the free [Testcontainers Desktop](https://testcontainers.com/desktop/) app will take care of most of the manual configuration. +When using those alternatives without Testcontainers Desktop, +sometimes some manual configuration might be necessary +(see further down for specific runtimes, or [Customizing Docker host detection](/features/configuration/#customizing-docker-host-detection) for general configuration mechanisms). +Alternative container runtimes are not actively tested in the main development workflow, +so not all Testcontainers features might be available. +If you have further questions about configuration details for your setup or whether it supports running Testcontainers-based tests, +please contact the Testcontainers team and other users from the Testcontainers community on [Slack](https://slack.testcontainers.org/). + +## Colima + +In order to run testcontainers against [colima](https://github.com/abiosoft/colima) the env vars below should be set + +```bash +colima start --network-address +export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock +export TESTCONTAINERS_HOST_OVERRIDE=$(colima ls -j | jq -r '.address') +export DOCKER_HOST="unix://${HOME}/.colima/default/docker.sock" +``` + +## Podman + +In order to run testcontainers against [podman](https://podman.io/) the env vars bellow should be set + +MacOS: + +```bash +{% raw %} +export DOCKER_HOST=unix://$(podman machine inspect --format '{{.ConnectionInfo.PodmanSocket.Path}}') +export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock +{% endraw %} +``` + +Linux: + +```bash +export DOCKER_HOST=unix://${XDG_RUNTIME_DIR}/podman/podman.sock +``` + +If you're running Podman in rootless mode, ensure to include the following line to disable Ryuk: + +```bash +export TESTCONTAINERS_RYUK_DISABLED=true +``` + +!!! note + Previous to version 1.19.0, `export TESTCONTAINERS_RYUK_PRIVILEGED=true` + was required for rootful mode. Starting with 1.19.0, this is no longer required. + +## Rancher Desktop + +In order to run testcontainers against [Rancher Desktop](https://rancherdesktop.io/) the env vars below should be set. + +If you're running Rancher Desktop as an administrator in a MacOS (M1) machine: + +Using QEMU emulation + +```bash +export TESTCONTAINERS_HOST_OVERRIDE=$(rdctl shell ip a show rd0 | awk '/inet / {sub("/.*",""); print $2}') +``` + +Using VZ emulation + +```bash +export TESTCONTAINERS_HOST_OVERRIDE=$(rdctl shell ip a show vznat | awk '/inet / {sub("/.*",""); print $2}') +``` + +If you're not running Rancher Desktop as an administrator in a MacOS (M1) machine: + +Using VZ emulation + +```bash +export DOCKER_HOST=unix://$HOME/.rd/docker.sock +export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock +export TESTCONTAINERS_HOST_OVERRIDE=$(rdctl shell ip a show vznat | awk '/inet / {sub("/.*",""); print $2}') +``` + +## Docker environment discovery + +Testcontainers will try to connect to a Docker daemon using the following strategies in order: + +* Environment variables: + * `DOCKER_HOST` + * `DOCKER_TLS_VERIFY` + * `DOCKER_CERT_PATH` +* Defaults: + * `DOCKER_HOST=https://localhost:2376` + * `DOCKER_TLS_VERIFY=1` + * `DOCKER_CERT_PATH=~/.docker` +* If Docker Machine is installed, the docker machine environment for the *first* machine found. Docker Machine needs to be on the PATH for this to succeed. +* If you're going to run your tests inside a container, please read [Patterns for running tests inside a docker container](continuous_integration/dind_patterns.md) first. + +## Docker registry authentication + +Testcontainers will try to authenticate to registries with supplied config using the following strategies in order: + +* Environment variables: + * `DOCKER_AUTH_CONFIG` +* Docker config + * At location specified in `DOCKER_CONFIG` or at `{HOME}/.docker/config.json` diff --git a/docs/supported_docker_environment/logging_config.md b/docs/supported_docker_environment/logging_config.md new file mode 100644 index 00000000000..6bbe0584434 --- /dev/null +++ b/docs/supported_docker_environment/logging_config.md @@ -0,0 +1,33 @@ +# Recommended logback configuration + +Testcontainers, and many of the libraries it uses, utilize SLF4J for logging. In order to see logs from Testcontainers, +your project should include an SLF4J implementation (Logback is recommended). The following example `logback-test.xml` +should be included in your classpath to show a reasonable level of log output: + +```xml + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n + + + + + + + + + + + + + +``` + +In order to troubleshoot issues with Testcontainers, increase the logging level of `org.testcontainers` to `DEBUG`: + +```xml + +``` + +Avoid changing the root logger's level to `DEBUG`, because this turns on debug logging for every package whose level isn't explicitly configured here, resulting in a large amount of log data. diff --git a/docs/supported_docker_environment/windows.md b/docs/supported_docker_environment/windows.md new file mode 100644 index 00000000000..9a50a844b04 --- /dev/null +++ b/docs/supported_docker_environment/windows.md @@ -0,0 +1,54 @@ +# Windows Support + +## Prerequisites +* [Docker for Windows](https://docs.docker.com/docker-for-windows/) needs to be installed + * Docker version 17.06 is confirmed to work on Windows 10 with Hyper-V. + * Testcontainers supports communication with Docker on Docker for Windows using named pipes. + * WSL2 backend is supported starting with Windows 10 2004. (**Beta**) + * Docker on Windows Server 2019 is currently **not supported** (also note [this issue](https://github.com/testcontainers/testcontainers-java/issues/2960)). + +## Limitations +The following features are not available or do not work correctly so make sure you do not use them or use them with +caution. The list may not be complete. + +### MySQL containers +* MySQL server prevents custom configuration file (ini-script) from being loaded due to security measures ([link to feature description](../modules/databases/index.md#using-an-init-script-from-a-file)) + +### Windows Container on Windows (WCOW) + +* WCOW is currently not supported, since Testcontainers uses auxiliary Linux containers for certain tasks and Docker for Windows does not support hybrid engine mode at the time of writing. + +## WSL2 backend + +Using Docker for Windows with WSL2 backend should work out of the box. + +However, there is an [existing issue](https://github.com/microsoft/WSL/issues/4694) in WSL/WSL2 that effects certain older Docker images. +The currently proposed workaround is to enable vsyscall emulation in the WSL2 kernel: +``` +[wsl2] +kernelCommandLine = vsyscall=emulate +``` + +## Windows Subsystem for Linux (WSL) + +Testcontainers supports communicating with Docker for Windows within the Windows Subsystem for Linux *([**WSL**](https://docs.microsoft.com/en-us/windows/wsl/about))*. +The following additional configurations steps are required: + ++ Expose the Docker for Windows daemon on tcp port `2375` without **TLS**. + *(Right-click the Docker for Windows icon on the task bar, click setting and go to `General`)*. + ++ Set the `DOCKER_HOST` environment variable inside the **WSL** shell to `tcp://localhost:2375`. + It is recommended to add this to your `~/.bashrc` file, so it’s available every time you open your terminal. + ++ **Optional** - Only if volumes are required: + Inside the **WSL** shell, modify the `/ect/wsl.conf` file to mount the Windows drives on `/` instead of on `/mnt/`. + *(Reboot required after this step)*. + Remember to share the drives, on which you will store your volumes, with Docker for Windows. + *(Right-click the Docker for Windows icon on the task bar, click setting and go to `Shared Drives`)*. + +More information about running Docker within the **WSL** can be found [here](https://nickjanetakis.com/blog/setting-up-docker-for-windows-and-wsl-to-work-flawlessly). + +## Reporting issues + +Please report any issues with the Windows build of Testcontainers [here](https://github.com/testcontainers/testcontainers-java/issues) +and be sure to note that you are using this on Windows. diff --git a/docs/template.html b/docs/template.html deleted file mode 100644 index fa439f2064a..00000000000 --- a/docs/template.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - {{NAME}} :: viewdocs.io - - - -
-
-

{{NAME}} :: index

-
-
- {{CONTENT}} -
-
- - - - - - diff --git a/docs/test_framework_integration/external.md b/docs/test_framework_integration/external.md new file mode 100644 index 00000000000..3396ff15d52 --- /dev/null +++ b/docs/test_framework_integration/external.md @@ -0,0 +1,10 @@ +# External Integrations + +The following Open Source frameworks add direct integration to Testcontainers + +| Framework | Source Code | Documentation | +| --- | --- | --- | +| jqwik | [jqwik-testcontainers](https://github.com/jqwik-team/jqwik-testcontainers) | [README](https://github.com/jqwik-team/jqwik-testcontainers) | +| Kotest | [Kotest Extensions Testcontainers](https://github.com/kotest/kotest/tree/master/kotest-extensions/kotest-extensions-testcontainers) | [kotest.io](https://kotest.io/docs/extensions/test_containers.html) | +| Synthesized | [Synthesized TDK-Testcontainers integration](https://github.com/synthesized-io/tdk-tc) | [synthesized.io](https://docs.synthesized.io/tdk/latest/user_guide/integrations/testcontainers) | +| TCI | [Testcontainers Infrastructure (TCI) Framework](https://github.com/xdev-software/tci-base) | [README](https://github.com/xdev-software/tci-base) | diff --git a/docs/test_framework_integration/junit_4.md b/docs/test_framework_integration/junit_4.md new file mode 100644 index 00000000000..8ab753e611f --- /dev/null +++ b/docs/test_framework_integration/junit_4.md @@ -0,0 +1,46 @@ +# JUnit 4 + +## `@Rule`/`@ClassRule` integration + +**JUnit4 `@Rule`/`@ClassRule`**: This mode starts the container before your tests and tears it down afterwards. + +Add a `@Rule` or `@ClassRule` annotated field to your test class, e.g.: + +```java +public class SimpleMySQLTest { + @Rule + public MySQLContainer mysql = new MySQLContainer(); + + // [...] +} +``` + + +## Manually controlling container lifecycle + +As an alternative, you can manually start the container in a `@BeforeClass`/`@Before` annotated method in your tests. Tear down will be done automatically on JVM exit, but you can of course also use an `@AfterClass`/`@After` annotated method to manually call the `stop()` method on your container. + +*Example of starting a container in a `@Before` annotated method:* + +```java +class SimpleMySQLTest { + private MySQLContainer mysql = new MySQLContainer(); + + @Before + void before() { + mysql.start(); + } + + @After + void after() { + mysql.stop(); + } + + // [...] +} +``` + +## Singleton containers + +Note that the [singleton container pattern](manual_lifecycle_control.md#singleton-containers) is also an option when +using JUnit 4. diff --git a/docs/test_framework_integration/junit_5.md b/docs/test_framework_integration/junit_5.md new file mode 100644 index 00000000000..aed2d515af9 --- /dev/null +++ b/docs/test_framework_integration/junit_5.md @@ -0,0 +1,78 @@ +# Jupiter / JUnit 5 + +This module provides an API that is based on the [JUnit Jupiter](https://junit.org/junit5/) extension model. + +The extension supports two modes: + +- containers that are restarted for every test method +- containers that are shared between all methods of a test class + +Note that Jupiter/JUnit 5 integration is packaged as a separate library JAR; see [below](#adding-testcontainers-junit-5-support-to-your-project-dependencies) for details. + +## Extension + +Jupiter integration is provided by means of the `@Testcontainers` annotation. + +The extension finds all fields that are annotated with `@Container` and calls their container lifecycle +methods (methods on the `Startable` interface). Containers declared as static fields will be shared between test +methods. They will be started only once before any test method is executed and stopped after the last test method has +executed. Containers declared as instance fields will be started and stopped for every test method. + +**Note:** This extension has only been tested with sequential test execution. Using it with parallel test execution is +unsupported and may have unintended side effects. + +*Example:* + +[Mixed Lifecycle](../../modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/MixedLifecycleTests.java) inside_block:testClass + + +## Examples + +To use the Testcontainers extension annotate your test class with `@Testcontainers`. + +### Restarted containers + +To define a restarted container, define an instance field inside your test class and annotate it with +the `@Container` annotation. + + +[Restarted Containers](../../modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersNestedRestartedContainerTests.java) inside_block:testClass + + + +### Shared containers + +Shared containers are defined as static fields in a top level test class and have to be annotated with `@Container`. +Note that shared containers can't be declared inside nested test classes. +This is because nested test classes have to be defined non-static and can't therefore have static fields. + + +[Shared Container](../../modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/MixedLifecycleTests.java) lines:18-23,32-33,35-36 + + +## Singleton containers + +Note that the [singleton container pattern](manual_lifecycle_control.md#singleton-containers) is also an option when +using JUnit 5. + +## Limitations + +This extension has only been tested with sequential test execution. Using it with parallel test execution is unsupported and may have unintended side effects. + +## Adding Testcontainers JUnit 5 support to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-junit-jupiter:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-junit-jupiter + {{latest_version}} + test + + ``` diff --git a/docs/test_framework_integration/manual_lifecycle_control.md b/docs/test_framework_integration/manual_lifecycle_control.md new file mode 100644 index 00000000000..e1005397c00 --- /dev/null +++ b/docs/test_framework_integration/manual_lifecycle_control.md @@ -0,0 +1,49 @@ +# Manual container lifecycle control + +Testcontainers is fully usable with any test framework, or with no framework at all. + +## Manually starting/stopping containers + +Containers can be started and stopped in code using `start()` and `stop()` methods. Additionally, container classes +implement `AutoCloseable`. This enables better assurance that the container will be stopped at the appropriate time. + +```java +try (GenericContainer container = new GenericContainer("imagename")) { + container.start(); + // ... use the container + // no need to call stop() afterwards +} +``` + +## Singleton containers + +Sometimes it might be useful to define a container that is only started once for several test classes. +There is no special support for this use case provided by the Testcontainers extension. +Instead this can be implemented using the following pattern: + +```java +abstract class AbstractContainerBaseTest { + + static final MySQLContainer MY_SQL_CONTAINER; + + static { + MY_SQL_CONTAINER = new MySQLContainer(); + MY_SQL_CONTAINER.start(); + } +} + +class FirstTest extends AbstractContainerBaseTest { + + @Test + void someTestMethod() { + String url = MY_SQL_CONTAINER.getJdbcUrl(); + + // create a connection and run test as normal + } +} +``` + +The singleton container is started only once when the base class is loaded. +The container can then be used by all inheriting test classes. +At the end of the test suite the [Ryuk container](https://github.com/testcontainers/moby-ryuk) +that is started by Testcontainers core will take care of stopping the singleton container. diff --git a/docs/test_framework_integration/spock.md b/docs/test_framework_integration/spock.md new file mode 100644 index 00000000000..3d50e0399d2 --- /dev/null +++ b/docs/test_framework_integration/spock.md @@ -0,0 +1,36 @@ +# Spock + +[Spock](https://github.com/spockframework/spock) extension for [Testcontainers](https://github.com/testcontainers/testcontainers-java) library, which allows to use Docker containers inside of Spock tests. + +## Usage + +### `@Testcontainers` class-annotation + +Specifying the `@Testcontainers` annotation will instruct Spock to start and stop all testcontainers accordingly. This annotation +can be mixed with Spock's `@Shared` annotation to indicate, that containers shouldn't be restarted between tests. + + +[PostgresContainerIT](../../modules/spock/src/test/groovy/org/testcontainers/spock/PostgresContainerIT.groovy) inside_block:PostgresContainerIT + + +## Adding Testcontainers Spock support to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-spock:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-spock + {{latest_version}} + test + + ``` + + +## Attributions +The initial version of this project was heavily inspired by the excellent [JUnit5 docker extension](https://github.com/FaustXVI/junit5-docker) by [FaustXVI](https://github.com/FaustXVI). diff --git a/docs/testcontainers-logo.svg b/docs/testcontainers-logo.svg new file mode 100644 index 00000000000..4b099f34a8e --- /dev/null +++ b/docs/testcontainers-logo.svg @@ -0,0 +1,22 @@ + + + Testcontainers + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/theme/main.html b/docs/theme/main.html new file mode 100644 index 00000000000..b3c01a3a271 --- /dev/null +++ b/docs/theme/main.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block analytics %} + +{% endblock %} + +{% block extrahead %} + + +{% endblock %} \ No newline at end of file diff --git a/docs/theme/partials/header.html b/docs/theme/partials/header.html new file mode 100644 index 00000000000..057ec4ae5e0 --- /dev/null +++ b/docs/theme/partials/header.html @@ -0,0 +1,150 @@ + + + +{% set class = "md-header" %} +{% if "navigation.tabs.sticky" in features %} + {% set class = class ~ " md-header--shadow md-header--lifted" %} +{% elif "navigation.tabs" not in features %} + {% set class = class ~ " md-header--shadow" %} +{% endif %} + +{% include "partials/tc-header.html" %} + + +
+ + + + {% if "navigation.tabs.sticky" in features %} + {% if "navigation.tabs" in features %} + {% include "partials/tabs.html" %} + {% endif %} + {% endif %} +
\ No newline at end of file diff --git a/docs/theme/partials/nav.html b/docs/theme/partials/nav.html new file mode 100644 index 00000000000..acf6c9565ea --- /dev/null +++ b/docs/theme/partials/nav.html @@ -0,0 +1,79 @@ + + + +{% set class = "md-nav md-nav--primary" %} +{% if "navigation.tabs" in features %} +{% set class = class ~ " md-nav--lifted" %} +{% endif %} +{% if "toc.integrate" in features %} +{% set class = class ~ " md-nav--integrated" %} +{% endif %} + + + \ No newline at end of file diff --git a/docs/theme/partials/tc-header.html b/docs/theme/partials/tc-header.html new file mode 100644 index 00000000000..246e9ff523a --- /dev/null +++ b/docs/theme/partials/tc-header.html @@ -0,0 +1,157 @@ +{% set header = ({ + "siteUrl": "https://testcontainers.com/", + "menuItems": [ + { + "label": "Desktop NEW", + "url": "https://testcontainers.com/desktop/" + }, + { + "label": "Cloud", + "url": "https://testcontainers.com/cloud/" + }, + { + "label": "Getting Started", + "url": "https://testcontainers.com/getting-started/" + }, + { + "label": "Guides", + "url": "https://testcontainers.com/guides/" + }, + { + "label": "Modules", + "url": "https://testcontainers.com/modules/" + }, + { + "label": "Docs", + "children": [ + { + "label": "Testcontainers for Java", + "url": "https://java.testcontainers.org/", + "image": "/language-logos/java.svg", + }, + { + "label": "Testcontainers for Go", + "url": "https://golang.testcontainers.org/", + "image": "/language-logos/go.svg", + }, + { + "label": "Testcontainers for .NET", + "url": "https://dotnet.testcontainers.org/", + "image": "/language-logos/dotnet.svg", + }, + { + "label": "Testcontainers for Node.js", + "url": "https://node.testcontainers.org/", + "image": "/language-logos/nodejs.svg", + }, + { + "label": "Testcontainers for Python", + "url": "https://testcontainers-python.readthedocs.io/en/latest/", + "image": "/language-logos/python.svg", + "external": true, + }, + { + "label": "Testcontainers for Rust", + "url": "https://docs.rs/testcontainers/latest/testcontainers/", + "image": "/language-logos/rust.svg", + "external": true, + }, + { + "label": "Testcontainers for Haskell", + "url": "https://github.com/testcontainers/testcontainers-hs", + "image": "/language-logos/haskell.svg", + "external": true, + }, + { + "label": "Testcontainers for Ruby", + "url": "https://github.com/testcontainers/testcontainers-ruby", + "image": "/language-logos/ruby.svg", + "external": true, + }, + ] + }, + { + "label": "Slack", + "url": "https://slack.testcontainers.org/", + "icon": "icon-slack", + }, + { + "label": "GitHub", + "url": "https://github.com/testcontainers", + "icon": "icon-github", + }, + ] +}) %} + + + + + + + + + + + diff --git a/docs/usage.md b/docs/usage.md deleted file mode 100644 index f6b381c6c8d..00000000000 --- a/docs/usage.md +++ /dev/null @@ -1,120 +0,0 @@ -# Usage - -### Prerequisites - -Docker or docker-machine (for OS X) must be installed on the machine you are running tests on. TestContainers currently requires JDK 1.8 and is compatible with JUnit. - -If you want to use TestContainers on Windows you can try the [alpha release](usage/windows_support.md). - -### Docker environment discovery - -Testcontainers will try to connect to a Docker daemon using the following strategies in order: - -* Environment variables: - * `DOCKER_HOST` (this should be set to an HTTP/HTTPS connection rather than a unix socket at present) - * `DOCKER_TLS_VERIFY` - * `DOCKER_CERT_PATH` -* Defaults: - * `DOCKER_HOST=https://localhost:2376` - * `DOCKER_TLS_VERIFY=1` - * `DOCKER_CERT_PATH=~/.docker` -* If Docker Machine is installed, the docker machine environment for the *first* machine found. Docker Machine needs to be on the PATH for this to succeed. -* If you're going to run your tests inside a container, please read [Running inside a Docker container](usage/inside_docker.md) first. - -### Usage modes - -* [Temporary database containers](usage/database_containers.md) - specialized MySQL, PostgreSQL, Oracle XE and Virtuoso container support -* [Webdriver containers](usage/webdriver_containers.md) - run a Dockerized Chrome or Firefox browser ready for Selenium/Webdriver operations - complete with automatic video recording -* [Generic containers](usage/generic_containers.md) - run any Docker container as a test dependency -* [Docker compose](usage/docker_compose.md) - reuse services defined in a Docker Compose YAML file -* [Dockerfile containers](usage/dockerfile.md) - run a container that is built on-the-fly from a Dockerfile - -## Maven dependencies - -TestContainers is distributed in a handful of Maven modules: - -* **testcontainers** for just core functionality, generic containers and docker-compose support -* **mysql**, **postgresql** or **oracle-xe** for database container support -* **selenium** for selenium/webdriver support -* **nginx** for nginx container support - -In the dependency description below, replace `--artifact name--` as appropriate and `--latest version--` with the [latest version available on Maven Central](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22org.testcontainers%22): - - - org.testcontainers - --artifact name-- - --latest version-- - - -### JitPack (unreleased versions) - -Alternatively, if you like to live on the bleeding edge, jitpack.io can be used to obtain SNAPSHOT versions. -Use the following dependency description instead: - - - com.github.testcontainers.testcontainers-java - --artifact name-- - -SNAPSHOT - - -A specific git revision (such as `093a3a4628`) can be used as a fixed version instead. The JitPack maven repository must also be declared, e.g.: - - - - jitpack.io - https://jitpack.io - - - -The [testcontainers examples project](https://github.com/testcontainers/testcontainers-java-examples) uses JitPack to fetch the latest, master version. - -### Shaded dependencies - -**Note**: Testcontainers uses the docker-java client library, which in turn depends on JAX-RS, Jersey and Jackson -libraries. These libraries in particular seem to be especially prone to conflicts with test code/applciation under test - code. As such, **these libraries are 'shaded' into the core testcontainers JAR** and relocated - under `org.testcontainers.shaded` to prevent class conflicts. - -## Logging - -Testcontainers, and many of the libraries it uses, utilize slf4j for logging. In order to see logs from Testcontainers, -your project should include an SLF4J implementation (Logback is recommended). The following example `logback-test.xml` -should be included in your classpath to show a reasonable level of log output: - -```xml - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n - - - - - - - - - - - - -``` - -## Using custom utility images - -Testcontainers uses additional docker images under some modes of execution: - -* `richnorth/ambassador:latest`, which is a TCP proxy used to expose Docker Compose container ports outside of the compose network. -* `richnorth/vncrecorder:latest`, which is a VNC->FLV recorder, used for capturing Webdriver test videos. - -> *N.B.:* both these images use the 'latest' tag, which could potentially affect repeatability of tests and compatibility with Testcontainers _if the image is ever changed_. This is a [known issue](https://github.com/testcontainers/testcontainers-java/issues/276) which will be addressed in the future. The current 'latest' version of these images will never be changed until they are replaced by a new image altogether. - -Last but not least, `alpine:3.2` image is used for Docker host IP address detection in some special cases. - -If it is necessary to override these image names (e.g. when using a private registry), you should create a file named `testcontainers.properties` and place it on the classpath with the following content: - -```properties -ambassador.container.image=replacement image name here -vncrecorder.container.image=replacement image name here -tinyimage.container.image=replacement image name here -``` diff --git a/docs/usage/database_containers.md b/docs/usage/database_containers.md deleted file mode 100644 index 9da5af91e81..00000000000 --- a/docs/usage/database_containers.md +++ /dev/null @@ -1,117 +0,0 @@ -# Database containers - -## Benefits - -You might want to use TestContainers' database support: - - * **Instead of H2 database for DAO unit tests that depend on database features that H2 doesn't emulate.** TestContainers is not as performant as H2, but does give you the benefit of 100% database compatibility (since it runs a real DB inside of a container). - * **Instead of a database running on the local machine or in a VM** for DAO unit tests or end-to-end integration tests that need a database to be present. In this context, the benefit of TestContainers is that the database always starts in a known state, without any contamination between test runs or on developers' local machines. - -> Note: Of course, it's still important to have as few tests that hit the database as possible, and make good use of mocks for components higher up the stack. - -You can obtain a temporary database in one of two ways: - - * **JUnit @Rule/@ClassRule**: this mode starts a database inside a container before your tests and tears it down afterwards. - * **Using a specially modified JDBC URL**: after making a very simple modification to your system's JDBC URL string, TestContainers will provide a disposable stand-in database that can be used without requiring modification to your application code. - -TestContainers currently supports MySQL, PostgreSQL, Oracle XE and Virtuoso. - -> Note: Oracle XE support does not bundle the proprietary Oracle JDBC drivers - you must provide these yourself. - -## Examples and options - -### JUnit rule - -Add a @Rule or @ClassRule to your test class, e.g.: - -```java -public class SimpleMySQLTest { - @Rule - public MySQLContainer mysql = new MySQLContainer(); -``` - -Now, in your test code (or a suitable setup method), you can obtain details necessary to connect to this database: - - * `mysql.getJdbcUrl()` provides a JDBC URL your code can connect to - * `mysql.getUsername()` provides the username your code should pass to the driver - * `mysql.getPassword()` provides the password your code should pass to the driver - -Note that if you use `@Rule`, you will be given an isolated container for each test method. If you use `@ClassRule`, you will get on isolated container for all the methods in the test class. - -Examples/Tests: - - * [MySQL](https://github.com/testcontainers/testcontainers-java/blob/master/modules/mysql/src/test/java/org/testcontainers/junit/SimpleMySQLTest.java) - * [PostgreSQL](https://github.com/testcontainers/testcontainers-java/blob/master/modules/postgresql/src/test/java/org/testcontainers/junit/SimplePostgreSQLTest.java) - * [Oracle-XE](https://github.com/testcontainers/testcontainers-java/blob/master/modules/oracle-xe/src/test/java/org/testcontainers/junit/SimpleOracleTest.java) - * [Virtuoso](https://github.com/testcontainers/testcontainers-java/blob/master/modules/virtuoso/src/test/java/org/testcontainers/junit/SimpleVirtuosoTest.java) - -### JDBC URL - -As long as you have TestContainers and the appropriate JDBC driver on your classpath, you can simply modify regular JDBC connection URLs to get a fresh containerized instance of the database each time your application starts up. - -_N.B:_ -* _TC needs to be on your application's classpath at runtime for this to work_ -* _For Spring Boot you need to specify the driver manually `spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver`_ - -**Original URL**: `jdbc:mysql://somehostname:someport/databasename` - -Insert `tc:` after `jdbc:` as follows. Note that the hostname, port and database name will be ignored; you can leave these as-is or set them to any value. - -### JDBC URL examples - -#### Simple TestContainers JDBC driver usage - -`jdbc:tc:mysql://somehostname:someport/databasename` - -*(Note: this will use the latest version of MySQL)* - -#### Using TestContainers with a fixed version - -`jdbc:tc:mysql:5.6.23://somehostname:someport/databasename` - -#### Using PostgreSQL - -`jdbc:tc:postgresql://hostname/databasename` - - -## Using an init script - -TestContainers can run an initscript after the database container is started, but before your code is given a connection to it. The script must be on the classpath, and is referenced as follows: - -`jdbc:tc:mysql://hostname/databasename?TC_INITSCRIPT=somepath/init_mysql.sql` - -This is useful if you have a fixed script for setting up database schema, etc. - -#### Using an init function - -Instead of running a fixed script for DB setup, it may be useful to call a Java function that you define. This is intended to allow you to trigger database schema migration tools. To do this, add TC_INITFUNCTION to the URL as follows, passing a full path to the class name and method: - - `jdbc:tc:mysql://hostname/databasename?TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction` - -The init function must be a public static method which takes a `java.sql.Connection` as its only parameter, e.g. -```java -public class JDBCDriverTest { - public static void sampleInitFunction(Connection connection) throws SQLException { - // e.g. run schema setup or Flyway/liquibase/etc DB migrations here... - } - ... -``` - -#### Overriding MySQL my.cnf settings - -For MySQL databases, it is possible to override configuration settings using resources on the classpath. Assuming `somepath/mysql_conf_override` -is a directory on the classpath containing .cnf files, the following URL can be used: - - `jdbc:tc:mysql:5.6://hostname/databasename?TC_MY_CNF=somepath/mysql_conf_override` - -Any .cnf files in this classpath directory will be mapped into the database container's /etc/mysql/conf.d directory, -and will be able to override server settings when the container starts. - -### Additional Non-standard Methods - -#### Virtuoso SPARQL Service URL - -VirtuosoContainer provides access to the SPARQL service URL -```java -String sparqlServiceUrl = ((VirtuosoContainer)container).getSparqlUrl(); -``` diff --git a/docs/usage/docker_compose.md b/docs/usage/docker_compose.md deleted file mode 100644 index dec5278dab7..00000000000 --- a/docs/usage/docker_compose.md +++ /dev/null @@ -1,55 +0,0 @@ -# Docker Compose - -## Benefits - -Similar to [generic containers](generic_containers.md) support, it's also possible to run a bespoke set of services -specified in a `docker-compose.yml` file. - -This is intended to be useful on projects where Docker Compose is already used in dev or other environments to define -services that an application may be dependent upon. - -Behind the scenes, Testcontainers actually launches a temporary Docker Compose client - in a container, of course, so -it's not necessary to have it installed on all developer/test machines. - -## Example - -A single class rule, pointing to a `docker-compose.yml` file, should be sufficient to launch any number of services -required by your tests: -```java -@ClassRule -public static DockerComposeContainer environment = - new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) - .withExposedService("redis_1", REDIS_PORT) - .withExposedService("elasticsearch_1", ELASTICSEARCH_PORT); -``` - -In this example, `compose-test.yml` should have content such as: -```yaml -redis: - image: redis -elasticsearch: - image: elasticsearch -``` - -Note that it is not necessary to define ports to be exposed in the YAML file; this would inhibit reuse/inclusion of the -file in other contexts. - -Instead, Testcontainers will spin up a small 'ambassador' container for every exposed service port, which will proxy -between the Compose-managed container and a port that's accessible to your tests. This is done using a separate, minimal -container that runs HAProxy in TCP proxying mode. - -## Accessing a container from tests - -The rule provides methods for discovering how your tests can interact with the containers: - -* `getServiceHost(serviceName, servicePort)` returns the IP address where the container is listening (via an ambassador - container) -* `getServicePort(serviceName, servicePort)` returns the Docker mapped port for a port that has been exposed (via an - ambassador container) - -For example, with the Redis example above, the following will allow your tests to access the Redis service: -```java -String redisUrl = environment.getServiceHost("redis_1", REDIS_PORT) - + ":" + - environment.getServicePort("redis_1", REDIS_PORT); -``` diff --git a/docs/usage/generic_containers.md b/docs/usage/generic_containers.md deleted file mode 100644 index f5748284586..00000000000 --- a/docs/usage/generic_containers.md +++ /dev/null @@ -1,49 +0,0 @@ -# Generic containers - -## Benefits - -Testcontainers' generic container support offers the most flexibility, and makes it easy to use virtually any container -images as temporary test dependencies. For example, if you might use it to test interactions with: - -* NoSQL databases or other data stores (e.g. redis, elasticsearch, mongo) -* Web servers/proxies (e.g. nginx, apache) -* Log services (e.g. logstash, kibana) -* Other services developed by your team/organization which are already dockerized - -## Example - -A generic container rule can be used with any public docker image; for example: -```java -// Set up a redis container -@ClassRule -public static GenericContainer redis = - new GenericContainer("redis:3.0.2") - .withExposedPorts(6379); - - -// Set up a plain OS container and customize environment, -// command and exposed ports. This just listens on port 80 -// and always returns '42' -@ClassRule -public static GenericContainer alpine = - new GenericContainer("alpine:3.2") - .withExposedPorts(80) - .withEnv("MAGIC_NUMBER", "42") - .withCommand("/bin/sh", "-c", - "while true; do echo \"$MAGIC_NUMBER\" | nc -l -p 80; done"); -``` - -These containers, as `@ClassRule`s, will be started before any tests in the class run, and will be destroyed after all -tests have run. - -## Accessing a container from tests - -The class rule provides methods for discovering how your tests can interact with the containers: - -* `getContainerIpAddress()` returns the IP address where the container is listening -* `getMappedPort(...)` returns the Docker mapped port for a port that has been exposed on the container - -For example, with the Redis example above, the following will allow your tests to access the Redis service: -```java -String redisUrl = redis.getContainerIpAddress() + ":" + redis.getMappedPort(6379); -``` \ No newline at end of file diff --git a/docs/usage/options.md b/docs/usage/options.md deleted file mode 100644 index 83211cc8f64..00000000000 --- a/docs/usage/options.md +++ /dev/null @@ -1,188 +0,0 @@ -# Options - -### Image - -With a generic container, you set the container image using a parameter to the rule constructor, e.g.: -```java -new GenericContainer("jboss/wildfly:9.0.1.Final") -``` - -### Exposing ports - -If you need to expose ports on the container, use the `withExposedPorts` method on the rule passing integer port numbers: -```java -new GenericContainer(...) - .withExposedPorts(22, 80, 8080) -``` - -or strings (optionally specifying tcp): -```java -new GenericContainer(...) - .withExposedPorts("22", "80/tcp", "8080/tcp") -``` - -### Environment variables - -To add environment variables to the container, use `withEnv`: -```java -new GenericContainer(...) - .withEnv("API_TOKEN", "foo") -``` - -### Command - -By default the container will execute whatever command is specified in the image's Dockerfile. To override this, and specify a different command, use `withCommand`: -```java -new GenericContainer(...) - .withCommand("/app/start.sh") -``` - -### Volume mapping - -It is possible to map a file or directory **on the classpath** into the container as a volume using `withClasspathResourceMapping`: -```java -new GenericContainer(...) - .withClasspathResourceMapping("redis.conf", - "/etc/redis.conf", - BindMode.READ_ONLY) -``` - -### Customizing the container - -It is possible to use the [`docker-java`](https://github.com/docker-java/docker-java) API directly to customize containers before creation. This is useful if there is a need to use advanced Docker features that are not exposed by the Testcontainers API. Any customizations you make using `withCreateContainerCmdModifier` will be applied _on top_ of the container definition that Testcontainers creates, but before it is created. - -For example, this can be used to change the container hostname: -```java -new GenericContainer<>("redis:3.0.2") - .withCreateContainerCmdModifier(cmd -> cmd.withHostName("the-cache")) - ... -``` - -... or modify container memory (see [this](https://fabiokung.com/2014/03/13/memory-inside-linux-containers/) if it does not appear to work): -```java -new GenericContainer<>("alpine:3.3") - .withCreateContainerCmdModifier(cmd -> cmd.withMemory((long) 4 * 1024 * 1024)) - .withCreateContainerCmdModifier(cmd -> cmd.withMemorySwap((long) 4 * 1024 * 1024)) - ... -``` - -> It is recommended to use this sparingly, and follow changes to the `docker-java` API if you choose to use this. It is typically quite stable, though. - -For what is possible, consult the [`docker-java CreateContainerCmd` source code](https://github.com/docker-java/docker-java/blob/master/src/main/java/com/github/dockerjava/api/command/CreateContainerCmd.java) - - - - -### Startup timeout - -Ordinarily Testcontainers will wait for up to 60 seconds for the container's first mapped network port to start listening. - -This simple measure provides a basic check whether a container is ready for use. - -If the default 60s timeout is not sufficient, it can be altered with the `withStartupTimeout()` method. - -If waiting for a listening TCP port is not sufficient to establish whether the container is ready, you can use the -`waitingFor()` method with other `WaitStrategy` implementations as shown below. - -#### Waiting for startup examples - -You can choose to wait for an HTTP(S) endpoint to return a particular status code. - -Waiting for 200 OK: -```java -@ClassRule -public static GenericContainer elasticsearch = - new GenericContainer("elasticsearch:2.3") - .withExposedPorts(9200) - .waitingFor(Wait.forHttp("/all")); -``` - -Wait for arbitrary status code on an HTTPS endpoint: -```java -@ClassRule -public static GenericContainer elasticsearch = - new GenericContainer("elasticsearch:2.3") - .withExposedPorts(9200) - .waitingFor( - Wait.forHttp("/all") - .forStatusCode(301) - .usingTls()); - ``` - -For futher options, check out the `Wait` convenience class, or the various subclasses of `WaitStrategy`. If none of these options -meet your requirements, you can create your own subclass of `AbstractWaitStrategy` with an appropriate wait -mechanism in `waitUntilReady()`. The `GenericContainer.waitingFor()` method accepts any valid `WaitStrategy`. - -### Following container output - -It is possible to capture container output using the `followOutput()` method. This method accepts a Consumer and (optionally) -a varargs list stating which of STDOUT, STDERR, or both, should be followed. If not specified, both will be followed. - -At present, container output will always begin from the time of container creation. - -Testcontainers includes some out-of-the-box Consumer implementations that can be used; examples follow. - -#### Streaming container output to an SLF4J logger - -Given an existing SLF4J logger instance named LOGGER: -```java -Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(LOGGER); -container.followOutput(logConsumer); -``` - -#### Capturing container output as a String -```java -ToStringConsumer toStringConsumer = new ToStringConsumer(); -container.followOutput(toStringConsumer, OutputType.STDOUT); - -String utf8String = toStringConsumer.toUtf8String(); - -// Or if the container output is not UTF-8 -String otherString = toStringConsumer.toString(CharSet.forName("ISO-8859-1")); -``` - -#### Waiting for container output to contain expected content - -`WaitingConsumer` will block until a frame of container output (usually a line) matches a provided predicate. - -A timeout may be specified, as shown in this example. -```java -WaitingConsumer consumer = new WaitingConsumer(); - -container.followOutput(consumer, STDOUT); - -consumer.waitUntil(frame -> - frame.getUtf8String().contains("STARTED"), 30, TimeUnit.SECONDS); -``` - -Additionally, as the Java 8 Consumer functional interface is used, Consumers may be composed together. This is -useful, for example, to capture all the container output but only when a matching string has been found. e.g.: -```java -WaitingConsumer waitingConsumer = new WaitingConsumer(); -ToStringConsumer toStringConsumer = new ToStringConsumer(); - -Consumer composedConsumer = toStringConsumer.andThen(waitingConsumer); -container.followOutput(composedConsumer); - -waitingConsumer.waitUntil(frame -> - frame.getUtf8String().contains("STARTED"), 30, TimeUnit.SECONDS); - -String utf8String = toStringConsumer.toUtf8String(); -``` - -### Executing a command - -Your test can execute a command inside a running container, similar to a `docker exec` call: -```java -myContainer.execInContainer("touch", "/tmp/foo"); -``` - -This can be useful for software that has a command line administration tool. You can also get the output from the command: -```java -ExecResult result = myContainer.execInContainer("tail", "-1", "/var/logs/foo"); -assertThat(result.getStdout().contains("message")); -``` - -There are two limitations: -* There's no way to get the return code of the executed command -* This isn't supported if your docker daemon uses the older "lxc" execution engine. diff --git a/docs/usage/webdriver_containers.md b/docs/usage/webdriver_containers.md deleted file mode 100644 index 621324c7ee1..00000000000 --- a/docs/usage/webdriver_containers.md +++ /dev/null @@ -1,85 +0,0 @@ -# Webdriver Containers - -Test Containers can be used to automatically instantiate and manage containers that include web browsers, such as those -from SeleniumHQ's [docker-selenium](https://github.com/SeleniumHQ/docker-selenium) project. - -## Benefits - -* Fully compatible with Selenium 2/Webdriver tests, by providing a `RemoteWebDriver` instance -* No need to have specific web browsers, or even a desktop environment, installed on test servers. The only dependency - is a working Docker installation and your Java JUnit test suite. -* Browsers are always launched from a fixed, clean image. This means no configuration drift from user changes or - automatic browser upgrades. -* Compatibility between browser version and the Selenium API is assured: a compatible version of the browser docker - images will be automatically selected to match the version of `selenium-api-*.jar` on the classpath -* Additionally the use of a clean browser prevents leakage of cookies, cached data or other state between tests. -* **VNC screen recording**: Test Containers can automatically record video of test runs (optionally capturing just - failing tests) - -Creation of browser containers is fast, so it's actually quite feasible to have a totally fresh browser instance for -every test. - -## Example - -The following field in your JUnit UI test class will prepare a container running Chrome: -```java -@Rule -public BrowserWebDriverContainer chrome = - new BrowserWebDriverContainer() - .withDesiredCapabilities(DesiredCapabilities.chrome()); -``` - -Now, instead of instantiating an instance of WebDriver directly, use the following to obtain an instance inside your -test methods: -```java -RemoteWebDriver driver = chrome.getWebDriver(); -``` - -You can then use this driver instance like a regular WebDriver. - -Note that, if you want to test a **web application running on the host machine** (the machine the JUnit tests are -running on - which is quite likely), you'll need to replace any references to `localhost` with an IP address that the -Docker container can reach. Use the `getHostIpAddress()` method, e.g.: -```java -driver.get("http://" + chrome.getHostIpAddress() + ":8080/"); -``` - -## Options - -### Other browsers - -At the moment, Chrome and Firefox are supported. To switch, simply change the first parameter to the rule constructor: -```java -new BrowserWebDriverContainer() - .withDesiredCapabilities(DesiredCapabilities.chrome()); -``` - -or -```java -new BrowserWebDriverContainer() - .withDesiredCapabilities(DesiredCapabilities.firefox()); -``` - -### Recording videos - -By default, no videos will be recorded. However, you can instruct Test Containers to capture videos for all tests or -just for failing tests. - -To do this, simply add extra parameters to the rule constructor: -```java -new BrowserWebDriverContainer() - .withDesiredCapabilities(DesiredCapabilities.chrome()) - .withRecordingMode(VncRecordingMode.RECORD_ALL, new File("./target/")) -``` - -or if you only want videos for test failures: -```java -new BrowserWebDriverContainer() - .withDesiredCapabilities(DesiredCapabilities.chrome()) - .withRecordingMode(VncRecordingMode.RECORD_FAILING, new File("./target/")) -``` -Note that the seconds parameter to `withRecordingMode` should be a directory where recordings can be saved. - -## More examples - -A few different examples are shown in [ChromeWebDriverContainerTest.java](https://github.com/testcontainers/testcontainers-java/blob/master/modules/selenium/src/test/java/org/testcontainers/junit/ChromeWebDriverContainerTest.java). \ No newline at end of file diff --git a/docs/usage/windows_support.md b/docs/usage/windows_support.md deleted file mode 100644 index 8a5c749dc8b..00000000000 --- a/docs/usage/windows_support.md +++ /dev/null @@ -1,24 +0,0 @@ -# Windows Support (Alpha) - -## Prerequisites -* [Docker Toolbox for Windows](https://docs.docker.com/engine/installation/windows/) needs to be installed -* Currently Docker for Windows without Toolbox (Linux VM) is not supported - -## Limitations -The following features are not available or do not work correctly so make sure you do not use them or use them with -caution. The list may not be complete. - -### Testing - -Testcontainers is not regularly tested on Windows, so please consider it to be at an alpha level of readiness. - -If you wish to use Testcontainers on Windows, please confirm that it works correctly for you before investing significant -effort. - -### MySQL containers -* MySQL server prevents custom configuration file (ini-script) from being loaded due to security measures ([link to feature description](database_containers.md#using-an-init-script)) - -## Reporting issues - -Please report any issues with the Windows build of Testcontainers [here](https://github.com/testcontainers/testcontainers-java/issues) -and be sure to note that you are using this on Windows. \ No newline at end of file diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000000..42d199db865 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,9 @@ +# testcontainers-java-examples + +> Practical examples of using Testcontainers. + +The code in this repository accompanies the following blog posts: + +* [JUnit integration testing with Docker and Testcontainers](https://rnorth.org/junit-integration-testing-with-docker-and-testcontainers) +* [Fun with Disque, Java and Spinach](https://rnorth.org/fun-with-disque-java-and-spinach) +* [Better JUnit Selenium testing with Docker and Testcontainers](https://rnorth.org/better-junit-selenium-testing-with-docker-and-testcontainers) diff --git a/examples/build.gradle b/examples/build.gradle new file mode 100644 index 00000000000..e80815cdae6 --- /dev/null +++ b/examples/build.gradle @@ -0,0 +1,31 @@ +// empty build.gradle for dependabot +plugins { + id 'com.diffplug.spotless' version '6.22.0' apply false +} + +apply from: "$rootDir/../gradle/ci-support.gradle" + +subprojects { + apply plugin:"java" + apply from: "$rootDir/../gradle/spotless.gradle" + apply plugin: 'checkstyle' + + repositories { + mavenCentral() + } + + test { + defaultCharacterEncoding = "UTF-8" + testLogging { + displayGranularity 1 + showStackTraces = true + exceptionFormat = 'full' + events "STARTED", "PASSED", "FAILED", "SKIPPED" + } + } + + checkstyle { + toolVersion = "10.23.0" + configFile = rootProject.file('../config/checkstyle/checkstyle.xml') + } +} diff --git a/examples/cucumber/build.gradle b/examples/cucumber/build.gradle new file mode 100644 index 00000000000..84dbc39a85e --- /dev/null +++ b/examples/cucumber/build.gradle @@ -0,0 +1,27 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation platform('org.seleniumhq.selenium:selenium-bom:4.35.0') + implementation 'org.seleniumhq.selenium:selenium-remote-driver' + implementation 'org.seleniumhq.selenium:selenium-firefox-driver' + implementation 'org.seleniumhq.selenium:selenium-chrome-driver' + + testImplementation platform('org.junit:junit-bom:5.13.4') + testImplementation 'org.junit.platform:junit-platform-suite' + testImplementation platform('io.cucumber:cucumber-bom:7.30.0') + testImplementation 'io.cucumber:cucumber-java' + testImplementation 'io.cucumber:cucumber-junit-platform-engine' + testImplementation 'org.testcontainers:testcontainers-selenium' + testImplementation 'org.assertj:assertj-core:3.27.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.3' +} + +test { + useJUnitPlatform() +} diff --git a/examples/cucumber/src/test/java/org/testcontainers/examples/CucumberTest.java b/examples/cucumber/src/test/java/org/testcontainers/examples/CucumberTest.java new file mode 100644 index 00000000000..5d6fe8c3f9c --- /dev/null +++ b/examples/cucumber/src/test/java/org/testcontainers/examples/CucumberTest.java @@ -0,0 +1,11 @@ +package org.testcontainers.examples; + +import io.cucumber.junit.platform.engine.Constants; +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.SelectPackages; +import org.junit.platform.suite.api.Suite; + +@Suite +@SelectPackages("org.testcontainers.examples") +@ConfigurationParameter(key = Constants.PLUGIN_PROPERTY_NAME, value = "pretty") +public class CucumberTest {} diff --git a/examples/cucumber/src/test/java/org/testcontainers/examples/Stepdefs.java b/examples/cucumber/src/test/java/org/testcontainers/examples/Stepdefs.java new file mode 100644 index 00000000000..aeccdd183d1 --- /dev/null +++ b/examples/cucumber/src/test/java/org/testcontainers/examples/Stepdefs.java @@ -0,0 +1,73 @@ +package org.testcontainers.examples; + +import io.cucumber.java.After; +import io.cucumber.java.Before; +import io.cucumber.java.Scenario; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.testcontainers.containers.BrowserWebDriverContainer; +import org.testcontainers.containers.BrowserWebDriverContainer.VncRecordingMode; +import org.testcontainers.lifecycle.TestDescription; + +import java.io.File; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +public class Stepdefs { + + private BrowserWebDriverContainer container = new BrowserWebDriverContainer() + .withCapabilities(new ChromeOptions()) + .withRecordingMode(VncRecordingMode.RECORD_ALL, new File("build")); + + private String location; + + private String answer; + + @Before + public void beforeScenario() { + container.start(); + } + + @After + public void afterScenario(Scenario scenario) { + container.afterTest( + new TestDescription() { + @Override + public String getTestId() { + return scenario.getId(); + } + + @Override + public String getFilesystemFriendlyName() { + return scenario.getName(); + } + }, + Optional.of(scenario).filter(Scenario::isFailed).map(__ -> new RuntimeException()) + ); + } + + @Given("^location is \"([^\"]*)\"$") + public void locationIs(String location) throws Exception { + this.location = location; + } + + @When("^I ask is it possible to search here$") + public void iAskIsItPossibleToSearchHere() throws Exception { + RemoteWebDriver driver = new RemoteWebDriver(container.getSeleniumAddress(), new ChromeOptions()); + driver.get(location); + List searchInputs = driver.findElements(By.tagName("input")); + answer = searchInputs != null && searchInputs.size() > 0 ? "YES" : "NOPE"; + } + + @Then("^I should be told \"([^\"]*)\"$") + public void iShouldBeTold(String expected) throws Exception { + assertThat(answer).isEqualTo(expected); + } +} diff --git a/examples/cucumber/src/test/resources/logback-test.xml b/examples/cucumber/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/cucumber/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/examples/cucumber/src/test/resources/org/testcontainers/examples/is_search_possible.feature b/examples/cucumber/src/test/resources/org/testcontainers/examples/is_search_possible.feature new file mode 100644 index 00000000000..48ab4a7d5dc --- /dev/null +++ b/examples/cucumber/src/test/resources/org/testcontainers/examples/is_search_possible.feature @@ -0,0 +1,12 @@ +Feature: Is it possible to search here? + Everybody wants to search something + + Scenario Outline: Is search possible here + Given location is "" + When I ask is it possible to search here + Then I should be told "" + + Examples: + | location | answer | + | https://www.google.com/ | YES | + | https://www.google.com/appsstatus | NOPE | \ No newline at end of file diff --git a/examples/gradle.properties b/examples/gradle.properties new file mode 100644 index 00000000000..dad41a4425a --- /dev/null +++ b/examples/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.parallel=false +org.gradle.caching=true diff --git a/examples/gradle/wrapper/gradle-wrapper.jar b/examples/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000000..1b33c55baab Binary files /dev/null and b/examples/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/gradle/wrapper/gradle-wrapper.properties b/examples/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000..78cb6e16a49 --- /dev/null +++ b/examples/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/gradlew b/examples/gradlew new file mode 100755 index 00000000000..23d15a93670 --- /dev/null +++ b/examples/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/gradlew.bat b/examples/gradlew.bat new file mode 100644 index 00000000000..5eed7ee8452 --- /dev/null +++ b/examples/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/hazelcast/build.gradle b/examples/hazelcast/build.gradle new file mode 100644 index 00000000000..c5086cc4d20 --- /dev/null +++ b/examples/hazelcast/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'com.hazelcast:hazelcast:5.3.8' + testImplementation 'ch.qos.logback:logback-classic:1.3.15' + testImplementation 'org.assertj:assertj-core:3.27.4' + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.0' +} + +test { + useJUnitPlatform() +} diff --git a/examples/hazelcast/src/test/java/org/testcontainers/examples/HazelcastTest.java b/examples/hazelcast/src/test/java/org/testcontainers/examples/HazelcastTest.java new file mode 100644 index 00000000000..4825474fecc --- /dev/null +++ b/examples/hazelcast/src/test/java/org/testcontainers/examples/HazelcastTest.java @@ -0,0 +1,125 @@ +package org.testcontainers.examples; + +import com.hazelcast.client.HazelcastClient; +import com.hazelcast.client.config.ClientConfig; +import com.hazelcast.core.HazelcastInstance; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.lifecycle.Startables; +import org.testcontainers.utility.DockerImageName; + +import java.util.concurrent.BlockingQueue; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Examples with Hazelcast using both a single container and a cluster with two containers. + */ +class HazelcastTest { + + // Hazelcast values + private static final String HZ_IMAGE_NAME = "hazelcast/hazelcast:5.2.0-slim"; + + private static final String HZ_CLUSTERNAME_ENV_NAME = "HZ_CLUSTERNAME"; + + private static final String HZ_NETWORK_JOIN_AZURE_ENABLED_ENV_NAME = "HZ_NETWORK_JOIN_AZURE_ENABLED"; + + private static final String HZ_NETWORK_JOIN_MULTICAST_ENABLED_ENV_NAME = "HZ_NETWORK_JOIN_MULTICAST_ENABLED"; + + private static final int DEFAULT_EXPOSED_PORT = 5701; + + // Test values + private static final String CLUSTER_STARTUP_LOG_MESSAGE_REGEX = ".*Members \\{size:2.*"; + + private static final String HOST_PORT_SEPARATOR = ":"; + + private static final String TEST_QUEUE_NAME = "test-queue"; + + private static final String TEST_CLUSTER_NAME = "test-cluster"; + + private static final String TEST_VALUE = "Hello!"; + + private static final String FALSE_VALUE = "false"; + + private static final String TRUE_VALUE = "true"; + + @AfterEach + void cleanUp() { + HazelcastClient.shutdownAll(); + } + + @Test + void singleHazelcastContainer() { + try ( + GenericContainer container = new GenericContainer<>(DockerImageName.parse(HZ_IMAGE_NAME)) + .withExposedPorts(DEFAULT_EXPOSED_PORT) + ) { + container.start(); + assertThat(container.isRunning()).isTrue(); + + ClientConfig clientConfig = new ClientConfig(); + clientConfig + .getNetworkConfig() + .addAddress(container.getHost() + HOST_PORT_SEPARATOR + container.getFirstMappedPort()); + HazelcastInstance client = HazelcastClient.newHazelcastClient(clientConfig); + + BlockingQueue queue = client.getQueue(TEST_QUEUE_NAME); + queue.put(TEST_VALUE); + assertThat(queue.take()).isEqualTo(TEST_VALUE); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during singleHazelcastContainer test", e); + } + } + + @Test + void hazelcastCluster() { + try ( + Network network = Network.newNetwork(); + GenericContainer container1 = new GenericContainer<>(DockerImageName.parse(HZ_IMAGE_NAME)) + .withExposedPorts(DEFAULT_EXPOSED_PORT) + .withEnv(HZ_CLUSTERNAME_ENV_NAME, TEST_CLUSTER_NAME) + // Flags necessary to run on Github Actions which runs on an Azure VM + .withEnv(HZ_NETWORK_JOIN_AZURE_ENABLED_ENV_NAME, FALSE_VALUE) + .withEnv(HZ_NETWORK_JOIN_MULTICAST_ENABLED_ENV_NAME, TRUE_VALUE) + .waitingFor(Wait.forLogMessage(CLUSTER_STARTUP_LOG_MESSAGE_REGEX, 1)) + .withNetwork(network); + GenericContainer container2 = new GenericContainer<>(DockerImageName.parse(HZ_IMAGE_NAME)) + .withExposedPorts(DEFAULT_EXPOSED_PORT) + .withEnv(HZ_CLUSTERNAME_ENV_NAME, TEST_CLUSTER_NAME) + // Flags necessary to run on Github Actions which runs on an Azure VM + .withEnv(HZ_NETWORK_JOIN_AZURE_ENABLED_ENV_NAME, FALSE_VALUE) + .withEnv(HZ_NETWORK_JOIN_MULTICAST_ENABLED_ENV_NAME, TRUE_VALUE) + .waitingFor(Wait.forLogMessage(CLUSTER_STARTUP_LOG_MESSAGE_REGEX, 1)) + .withNetwork(network) + ) { + Startables.deepStart(container1, container2).join(); + assertThat(container1.isRunning()).isTrue(); + assertThat(container2.isRunning()).isTrue(); + + ClientConfig clientConfig = new ClientConfig(); + clientConfig + .setClusterName(TEST_CLUSTER_NAME) + .getNetworkConfig() + // Uncomment the next line to remove the "WARNING: ...Could not connect to member..." message + //.setSmartRouting(false) + .addAddress(container1.getHost() + HOST_PORT_SEPARATOR + container1.getFirstMappedPort()) + .addAddress(container2.getHost() + HOST_PORT_SEPARATOR + container2.getFirstMappedPort()); + + HazelcastInstance client = HazelcastClient.newHazelcastClient(clientConfig); + + assertThat(client.getCluster().getMembers()).hasSize(2); + + BlockingQueue queue = client.getQueue(TEST_QUEUE_NAME); + queue.put(TEST_VALUE); + + assertThat(queue.take()).isEqualTo(TEST_VALUE); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during hazelcastCluster test", e); + } + } +} diff --git a/examples/hazelcast/src/test/resources/logback-test.xml b/examples/hazelcast/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/hazelcast/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/examples/immudb/build.gradle b/examples/immudb/build.gradle new file mode 100644 index 00000000000..a4d656dd762 --- /dev/null +++ b/examples/immudb/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'io.codenotary:immudb4j:1.0.1' + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.testcontainers:testcontainers-junit-jupiter' + testImplementation 'org.assertj:assertj-core:3.27.4' + testImplementation 'com.google.guava:guava:23.0' + testImplementation 'ch.qos.logback:logback-classic:1.3.15' + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.0' +} + +test { + useJUnitPlatform() +} diff --git a/examples/immudb/src/test/java/ImmuDbTest.java b/examples/immudb/src/test/java/ImmuDbTest.java new file mode 100644 index 00000000000..fc0c2ad9eca --- /dev/null +++ b/examples/immudb/src/test/java/ImmuDbTest.java @@ -0,0 +1,75 @@ +import io.codenotary.immudb4j.Entry; +import io.codenotary.immudb4j.ImmuClient; +import io.codenotary.immudb4j.exceptions.VerificationException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test class for the ImmuDbClient. + */ +@Testcontainers +class ImmuDbTest { + + // Default port for the ImmuDb server + private static final int IMMUDB_PORT = 3322; + + // Default username for the ImmuDb server + private final String IMMUDB_USER = "immudb"; + + // Default password for the ImmuDb server + private final String IMMUDB_PASSWORD = "immudb"; + + // Default database name for the ImmuDb server + private final String IMMUDB_DATABASE = "defaultdb"; + + // Test container for the ImmuDb database, with the latest version of the image and exposed port + @Container + public static final GenericContainer immuDbContainer = new GenericContainer<>("codenotary/immudb:1.3") + .withExposedPorts(IMMUDB_PORT) + .waitingFor(Wait.forLogMessage(".*Web API server enabled.*", 1)); + + // ImmuClient used to interact with the DB + private ImmuClient immuClient; + + @BeforeEach + void setUp() { + this.immuClient = + ImmuClient + .newBuilder() + .withServerUrl(immuDbContainer.getHost()) + .withServerPort(immuDbContainer.getMappedPort(IMMUDB_PORT)) + .build(); + this.immuClient.openSession(IMMUDB_DATABASE, IMMUDB_USER, IMMUDB_PASSWORD); + } + + @AfterEach + void tearDown() { + this.immuClient.closeSession(); + } + + @Test + void testGetValue() { + try { + immuClient.set("test1", "test2".getBytes()); + + Entry entry = immuClient.verifiedGet("test1"); + + if (entry != null) { + byte[] value = entry.getValue(); + assertThat(new String(value)).isEqualTo("test2"); + } else { + Assertions.fail(); + } + } catch (VerificationException e) { + Assertions.fail(); + } + } +} diff --git a/examples/immudb/src/test/resources/logback-test.xml b/examples/immudb/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/immudb/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/examples/kafka-cluster/build.gradle b/examples/kafka-cluster/build.gradle new file mode 100644 index 00000000000..67ca7e4e37c --- /dev/null +++ b/examples/kafka-cluster/build.gradle @@ -0,0 +1,24 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + testCompileOnly "org.projectlombok:lombok:1.18.38" + testAnnotationProcessor "org.projectlombok:lombok:1.18.38" + testImplementation 'org.testcontainers:testcontainers-kafka' + testImplementation 'org.apache.kafka:kafka-clients:4.1.0' + testImplementation 'org.assertj:assertj-core:3.27.4' + testImplementation 'com.google.guava:guava:23.0' + testImplementation 'ch.qos.logback:logback-classic:1.3.15' + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + testImplementation 'org.awaitility:awaitility:4.3.0' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.0' +} + +test { + useJUnitPlatform() +} diff --git a/examples/kafka-cluster/src/test/java/com/example/kafkacluster/ApacheKafkaContainerCluster.java b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/ApacheKafkaContainerCluster.java new file mode 100644 index 00000000000..0e56bd9d758 --- /dev/null +++ b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/ApacheKafkaContainerCluster.java @@ -0,0 +1,106 @@ +package com.example.kafkacluster; + +import org.apache.kafka.common.Uuid; +import org.awaitility.Awaitility; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.kafka.KafkaContainer; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.Collection; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ApacheKafkaContainerCluster implements Startable { + + private final int brokersNum; + + private final Network network; + + private final Collection brokers; + + public ApacheKafkaContainerCluster(String version, int brokersNum, int internalTopicsRf) { + if (brokersNum <= 0) { + throw new IllegalArgumentException("brokersNum '" + brokersNum + "' must be greater than 0"); + } + if (internalTopicsRf <= 0 || internalTopicsRf > brokersNum) { + throw new IllegalArgumentException( + "internalTopicsRf '" + + internalTopicsRf + + "' must be less than or equal to brokersNum and greater than 0" + ); + } + + this.brokersNum = brokersNum; + this.network = Network.newNetwork(); + + String controllerQuorumVoters = IntStream + .range(0, brokersNum) + .mapToObj(brokerNum -> String.format("%d@broker-%d:9094", brokerNum, brokerNum)) + .collect(Collectors.joining(",")); + + String clusterId = Uuid.randomUuid().toString(); + + this.brokers = + IntStream + .range(0, brokersNum) + .mapToObj(brokerNum -> { + return new KafkaContainer(DockerImageName.parse("apache/kafka").withTag(version)) + .withNetwork(this.network) + .withNetworkAliases("broker-" + brokerNum) + .withEnv("CLUSTER_ID", clusterId) + .withEnv("KAFKA_BROKER_ID", brokerNum + "") + .withEnv("KAFKA_NODE_ID", brokerNum + "") + .withEnv("KAFKA_CONTROLLER_QUORUM_VOTERS", controllerQuorumVoters) + .withEnv("KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR", internalTopicsRf + "") + .withEnv("KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS", "0") + .withEnv("KAFKA_OFFSETS_TOPIC_NUM_PARTITIONS", internalTopicsRf + "") + .withEnv("KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR", internalTopicsRf + "") + .withEnv("KAFKA_TRANSACTION_STATE_LOG_MIN_ISR", internalTopicsRf + "") + .withStartupTimeout(Duration.ofMinutes(1)); + }) + .collect(Collectors.toList()); + } + + public Collection getBrokers() { + return this.brokers; + } + + public String getBootstrapServers() { + return brokers.stream().map(KafkaContainer::getBootstrapServers).collect(Collectors.joining(",")); + } + + @Override + public void start() { + // Needs to start all the brokers at once + brokers.parallelStream().forEach(GenericContainer::start); + + Awaitility + .await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted(() -> { + Container.ExecResult result = + this.brokers.stream() + .findFirst() + .get() + .execInContainer( + "sh", + "-c", + "/opt/kafka/bin/kafka-log-dirs.sh --bootstrap-server localhost:9093 --describe | grep -o '\"broker\"' | wc -l" + ); + String brokers = result.getStdout().replace("\n", ""); + + assertThat(brokers).asInt().isEqualTo(this.brokersNum); + }); + } + + @Override + public void stop() { + this.brokers.parallelStream().forEach(GenericContainer::stop); + } +} diff --git a/examples/kafka-cluster/src/test/java/com/example/kafkacluster/ApacheKafkaContainerClusterTest.java b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/ApacheKafkaContainerClusterTest.java new file mode 100644 index 00000000000..38ac274706b --- /dev/null +++ b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/ApacheKafkaContainerClusterTest.java @@ -0,0 +1,94 @@ +package com.example.kafkacluster; + +import com.google.common.collect.ImmutableMap; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +class ApacheKafkaContainerClusterTest { + + @Test + void testKafkaContainerCluster() throws Exception { + try (ApacheKafkaContainerCluster cluster = new ApacheKafkaContainerCluster("3.8.0", 3, 2)) { + cluster.start(); + String bootstrapServers = cluster.getBootstrapServers(); + + assertThat(cluster.getBrokers()).hasSize(3); + + testKafkaFunctionality(bootstrapServers, 3, 2); + } + } + + protected void testKafkaFunctionality(String bootstrapServers, int partitions, int rf) throws Exception { + try ( + AdminClient adminClient = AdminClient.create( + ImmutableMap.of(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers) + ); + KafkaProducer producer = new KafkaProducer<>( + ImmutableMap.of( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, + bootstrapServers, + ProducerConfig.CLIENT_ID_CONFIG, + UUID.randomUUID().toString() + ), + new StringSerializer(), + new StringSerializer() + ); + KafkaConsumer consumer = new KafkaConsumer<>( + ImmutableMap.of( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, + bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG, + "tc-" + UUID.randomUUID(), + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, + "earliest" + ), + new StringDeserializer(), + new StringDeserializer() + ); + ) { + String topicName = "messages"; + + Collection topics = Collections.singletonList(new NewTopic(topicName, partitions, (short) rf)); + adminClient.createTopics(topics).all().get(30, TimeUnit.SECONDS); + + consumer.subscribe(Collections.singletonList(topicName)); + + producer.send(new ProducerRecord<>(topicName, "testcontainers", "rulezzz")).get(); + + Awaitility + .await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + ConsumerRecords records = consumer.poll(Duration.ofMillis(100)); + + assertThat(records) + .hasSize(1) + .extracting(ConsumerRecord::topic, ConsumerRecord::key, ConsumerRecord::value) + .containsExactly(tuple(topicName, "testcontainers", "rulezzz")); + }); + + consumer.unsubscribe(); + } + } +} diff --git a/examples/kafka-cluster/src/test/java/com/example/kafkacluster/ConfluentKafkaContainerCluster.java b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/ConfluentKafkaContainerCluster.java new file mode 100644 index 00000000000..f09c3072ea3 --- /dev/null +++ b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/ConfluentKafkaContainerCluster.java @@ -0,0 +1,107 @@ +package com.example.kafkacluster; + +import org.apache.kafka.common.Uuid; +import org.awaitility.Awaitility; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.kafka.ConfluentKafkaContainer; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.Collection; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ConfluentKafkaContainerCluster implements Startable { + + private final int brokersNum; + + private final Network network; + + private final Collection brokers; + + public ConfluentKafkaContainerCluster(String confluentPlatformVersion, int brokersNum, int internalTopicsRf) { + if (brokersNum <= 0) { + throw new IllegalArgumentException("brokersNum '" + brokersNum + "' must be greater than 0"); + } + if (internalTopicsRf <= 0 || internalTopicsRf > brokersNum) { + throw new IllegalArgumentException( + "internalTopicsRf '" + + internalTopicsRf + + "' must be less than or equal to brokersNum and greater than 0" + ); + } + + this.brokersNum = brokersNum; + this.network = Network.newNetwork(); + + String controllerQuorumVoters = IntStream + .range(0, brokersNum) + .mapToObj(brokerNum -> String.format("%d@broker-%d:9094", brokerNum, brokerNum)) + .collect(Collectors.joining(",")); + + String clusterId = Uuid.randomUuid().toString(); + + this.brokers = + IntStream + .range(0, brokersNum) + .mapToObj(brokerNum -> { + return new ConfluentKafkaContainer( + DockerImageName.parse("confluentinc/cp-kafka").withTag(confluentPlatformVersion) + ) + .withNetwork(this.network) + .withNetworkAliases("broker-" + brokerNum) + .withEnv("CLUSTER_ID", clusterId) + .withEnv("KAFKA_BROKER_ID", brokerNum + "") + .withEnv("KAFKA_NODE_ID", brokerNum + "") + .withEnv("KAFKA_CONTROLLER_QUORUM_VOTERS", controllerQuorumVoters) + .withEnv("KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR", internalTopicsRf + "") + .withEnv("KAFKA_OFFSETS_TOPIC_NUM_PARTITIONS", internalTopicsRf + "") + .withEnv("KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR", internalTopicsRf + "") + .withEnv("KAFKA_TRANSACTION_STATE_LOG_MIN_ISR", internalTopicsRf + "") + .withStartupTimeout(Duration.ofMinutes(1)); + }) + .collect(Collectors.toList()); + } + + public Collection getBrokers() { + return this.brokers; + } + + public String getBootstrapServers() { + return brokers.stream().map(ConfluentKafkaContainer::getBootstrapServers).collect(Collectors.joining(",")); + } + + @Override + public void start() { + // Needs to start all the brokers at once + brokers.parallelStream().forEach(GenericContainer::start); + + Awaitility + .await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted(() -> { + Container.ExecResult result = + this.brokers.stream() + .findFirst() + .get() + .execInContainer( + "sh", + "-c", + "kafka-metadata-shell --snapshot /var/lib/kafka/data/__cluster_metadata-0/00000000000000000000.log ls /brokers | wc -l" + ); + String brokers = result.getStdout().replace("\n", ""); + + assertThat(brokers).asInt().isEqualTo(this.brokersNum); + }); + } + + @Override + public void stop() { + this.brokers.parallelStream().forEach(GenericContainer::stop); + } +} diff --git a/examples/kafka-cluster/src/test/java/com/example/kafkacluster/ConfluentKafkaContainerClusterTest.java b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/ConfluentKafkaContainerClusterTest.java new file mode 100644 index 00000000000..3bb38cb7152 --- /dev/null +++ b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/ConfluentKafkaContainerClusterTest.java @@ -0,0 +1,94 @@ +package com.example.kafkacluster; + +import com.google.common.collect.ImmutableMap; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +class ConfluentKafkaContainerClusterTest { + + @Test + void testKafkaContainerCluster() throws Exception { + try (ConfluentKafkaContainerCluster cluster = new ConfluentKafkaContainerCluster("7.4.0", 3, 2)) { + cluster.start(); + String bootstrapServers = cluster.getBootstrapServers(); + + assertThat(cluster.getBrokers()).hasSize(3); + + testKafkaFunctionality(bootstrapServers, 3, 2); + } + } + + protected void testKafkaFunctionality(String bootstrapServers, int partitions, int rf) throws Exception { + try ( + AdminClient adminClient = AdminClient.create( + ImmutableMap.of(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers) + ); + KafkaProducer producer = new KafkaProducer<>( + ImmutableMap.of( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, + bootstrapServers, + ProducerConfig.CLIENT_ID_CONFIG, + UUID.randomUUID().toString() + ), + new StringSerializer(), + new StringSerializer() + ); + KafkaConsumer consumer = new KafkaConsumer<>( + ImmutableMap.of( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, + bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG, + "tc-" + UUID.randomUUID(), + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, + "earliest" + ), + new StringDeserializer(), + new StringDeserializer() + ); + ) { + String topicName = "messages"; + + Collection topics = Collections.singletonList(new NewTopic(topicName, partitions, (short) rf)); + adminClient.createTopics(topics).all().get(30, TimeUnit.SECONDS); + + consumer.subscribe(Collections.singletonList(topicName)); + + producer.send(new ProducerRecord<>(topicName, "testcontainers", "rulezzz")).get(); + + Awaitility + .await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + ConsumerRecords records = consumer.poll(Duration.ofMillis(100)); + + assertThat(records) + .hasSize(1) + .extracting(ConsumerRecord::topic, ConsumerRecord::key, ConsumerRecord::value) + .containsExactly(tuple(topicName, "testcontainers", "rulezzz")); + }); + + consumer.unsubscribe(); + } + } +} diff --git a/examples/kafka-cluster/src/test/java/com/example/kafkacluster/KafkaContainerCluster.java b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/KafkaContainerCluster.java new file mode 100644 index 00000000000..aa22cceadb0 --- /dev/null +++ b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/KafkaContainerCluster.java @@ -0,0 +1,115 @@ +package com.example.kafkacluster; + +import lombok.SneakyThrows; +import org.awaitility.Awaitility; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.KafkaContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.Collection; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Provides an easy way to launch a Kafka cluster with multiple brokers. + */ +public class KafkaContainerCluster implements Startable { + + private final int brokersNum; + + private final Network network; + + private final GenericContainer zookeeper; + + private final Collection brokers; + + public KafkaContainerCluster(String confluentPlatformVersion, int brokersNum, int internalTopicsRf) { + if (brokersNum <= 0) { + throw new IllegalArgumentException("brokersNum '" + brokersNum + "' must be greater than 0"); + } + if (internalTopicsRf <= 0 || internalTopicsRf > brokersNum) { + throw new IllegalArgumentException( + "internalTopicsRf '" + + internalTopicsRf + + "' must be less than or equal to brokersNum and greater than 0" + ); + } + + this.brokersNum = brokersNum; + this.network = Network.newNetwork(); + + this.zookeeper = + new GenericContainer<>(DockerImageName.parse("confluentinc/cp-zookeeper").withTag(confluentPlatformVersion)) + .withNetwork(network) + .withNetworkAliases("zookeeper") + .withEnv("ZOOKEEPER_CLIENT_PORT", String.valueOf(KafkaContainer.ZOOKEEPER_PORT)); + + this.brokers = + IntStream + .range(0, this.brokersNum) + .mapToObj(brokerNum -> { + return new KafkaContainer( + DockerImageName.parse("confluentinc/cp-kafka").withTag(confluentPlatformVersion) + ) + .withNetwork(this.network) + .withNetworkAliases("broker-" + brokerNum) + .dependsOn(this.zookeeper) + .withExternalZookeeper("zookeeper:" + KafkaContainer.ZOOKEEPER_PORT) + .withEnv("KAFKA_BROKER_ID", brokerNum + "") + .withEnv("KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR", internalTopicsRf + "") + .withEnv("KAFKA_OFFSETS_TOPIC_NUM_PARTITIONS", internalTopicsRf + "") + .withEnv("KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR", internalTopicsRf + "") + .withEnv("KAFKA_TRANSACTION_STATE_LOG_MIN_ISR", internalTopicsRf + "") + .withStartupTimeout(Duration.ofMinutes(1)); + }) + .collect(Collectors.toList()); + } + + public Collection getBrokers() { + return this.brokers; + } + + public String getBootstrapServers() { + return brokers.stream().map(KafkaContainer::getBootstrapServers).collect(Collectors.joining(",")); + } + + private Stream> allContainers() { + return Stream.concat(this.brokers.stream(), Stream.of(this.zookeeper)); + } + + @Override + @SneakyThrows + public void start() { + // sequential start to avoid resource contention on CI systems with weaker hardware + brokers.forEach(GenericContainer::start); + + Awaitility + .await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted(() -> { + Container.ExecResult result = + this.zookeeper.execInContainer( + "sh", + "-c", + "zookeeper-shell zookeeper:" + + KafkaContainer.ZOOKEEPER_PORT + + " ls /brokers/ids | tail -n 1" + ); + String brokers = result.getStdout(); + + assertThat(brokers.split(",")).hasSize(this.brokersNum); + }); + } + + @Override + public void stop() { + allContainers().parallel().forEach(GenericContainer::stop); + } +} diff --git a/examples/kafka-cluster/src/test/java/com/example/kafkacluster/KafkaContainerClusterTest.java b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/KafkaContainerClusterTest.java new file mode 100644 index 00000000000..6a66cf0ca2e --- /dev/null +++ b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/KafkaContainerClusterTest.java @@ -0,0 +1,118 @@ +package com.example.kafkacluster; + +import com.google.common.collect.ImmutableMap; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +class KafkaContainerClusterTest { + + @Test + void testKafkaContainerCluster() throws Exception { + try (KafkaContainerCluster cluster = new KafkaContainerCluster("6.2.1", 3, 2)) { + cluster.start(); + String bootstrapServers = cluster.getBootstrapServers(); + + assertThat(cluster.getBrokers()).hasSize(3); + + testKafkaFunctionality(bootstrapServers, 3, 2); + } + } + + @Test + void testKafkaContainerKraftCluster() throws Exception { + try (KafkaContainerKraftCluster cluster = new KafkaContainerKraftCluster("7.0.0", 3, 2)) { + cluster.start(); + String bootstrapServers = cluster.getBootstrapServers(); + + assertThat(cluster.getBrokers()).hasSize(3); + + testKafkaFunctionality(bootstrapServers, 3, 2); + } + } + + @Test + void testKafkaContainerKraftClusterAfterConfluentPlatform740() throws Exception { + try (KafkaContainerKraftCluster cluster = new KafkaContainerKraftCluster("7.4.0", 3, 2)) { + cluster.start(); + String bootstrapServers = cluster.getBootstrapServers(); + + assertThat(cluster.getBrokers()).hasSize(3); + + testKafkaFunctionality(bootstrapServers, 3, 2); + } + } + + protected void testKafkaFunctionality(String bootstrapServers, int partitions, int rf) throws Exception { + try ( + AdminClient adminClient = AdminClient.create( + ImmutableMap.of(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers) + ); + KafkaProducer producer = new KafkaProducer<>( + ImmutableMap.of( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, + bootstrapServers, + ProducerConfig.CLIENT_ID_CONFIG, + UUID.randomUUID().toString() + ), + new StringSerializer(), + new StringSerializer() + ); + KafkaConsumer consumer = new KafkaConsumer<>( + ImmutableMap.of( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, + bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG, + "tc-" + UUID.randomUUID(), + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, + "earliest" + ), + new StringDeserializer(), + new StringDeserializer() + ); + ) { + String topicName = "messages"; + + Collection topics = Collections.singletonList(new NewTopic(topicName, partitions, (short) rf)); + adminClient.createTopics(topics).all().get(30, TimeUnit.SECONDS); + + consumer.subscribe(Collections.singletonList(topicName)); + + producer.send(new ProducerRecord<>(topicName, "testcontainers", "rulezzz")).get(); + + Awaitility + .await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + ConsumerRecords records = consumer.poll(Duration.ofMillis(100)); + + assertThat(records) + .hasSize(1) + .extracting(ConsumerRecord::topic, ConsumerRecord::key, ConsumerRecord::value) + .containsExactly(tuple(topicName, "testcontainers", "rulezzz")); + }); + + consumer.unsubscribe(); + } + } +} diff --git a/examples/kafka-cluster/src/test/java/com/example/kafkacluster/KafkaContainerKraftCluster.java b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/KafkaContainerKraftCluster.java new file mode 100644 index 00000000000..264f751ecc6 --- /dev/null +++ b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/KafkaContainerKraftCluster.java @@ -0,0 +1,108 @@ +package com.example.kafkacluster; + +import org.apache.kafka.common.Uuid; +import org.awaitility.Awaitility; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.KafkaContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.Collection; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; + +public class KafkaContainerKraftCluster implements Startable { + + private final int brokersNum; + + private final Network network; + + private final Collection brokers; + + public KafkaContainerKraftCluster(String confluentPlatformVersion, int brokersNum, int internalTopicsRf) { + if (brokersNum <= 0) { + throw new IllegalArgumentException("brokersNum '" + brokersNum + "' must be greater than 0"); + } + if (internalTopicsRf <= 0 || internalTopicsRf > brokersNum) { + throw new IllegalArgumentException( + "internalTopicsRf '" + + internalTopicsRf + + "' must be less than or equal to brokersNum and greater than 0" + ); + } + + this.brokersNum = brokersNum; + this.network = Network.newNetwork(); + + String controllerQuorumVoters = IntStream + .range(0, brokersNum) + .mapToObj(brokerNum -> String.format("%d@broker-%d:9094", brokerNum, brokerNum)) + .collect(Collectors.joining(",")); + + String clusterId = Uuid.randomUuid().toString(); + + this.brokers = + IntStream + .range(0, brokersNum) + .mapToObj(brokerNum -> { + return new KafkaContainer( + DockerImageName.parse("confluentinc/cp-kafka").withTag(confluentPlatformVersion) + ) + .withNetwork(this.network) + .withNetworkAliases("broker-" + brokerNum) + .withKraft() + .withClusterId(clusterId) + .withEnv("KAFKA_BROKER_ID", brokerNum + "") + .withEnv("KAFKA_NODE_ID", brokerNum + "") + .withEnv("KAFKA_CONTROLLER_QUORUM_VOTERS", controllerQuorumVoters) + .withEnv("KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR", internalTopicsRf + "") + .withEnv("KAFKA_OFFSETS_TOPIC_NUM_PARTITIONS", internalTopicsRf + "") + .withEnv("KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR", internalTopicsRf + "") + .withEnv("KAFKA_TRANSACTION_STATE_LOG_MIN_ISR", internalTopicsRf + "") + .withStartupTimeout(Duration.ofMinutes(1)); + }) + .collect(Collectors.toList()); + } + + public Collection getBrokers() { + return this.brokers; + } + + public String getBootstrapServers() { + return brokers.stream().map(KafkaContainer::getBootstrapServers).collect(Collectors.joining(",")); + } + + @Override + public void start() { + // Needs to start all the brokers at once + brokers.parallelStream().forEach(GenericContainer::start); + + Awaitility + .await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted(() -> { + Container.ExecResult result = + this.brokers.stream() + .findFirst() + .get() + .execInContainer( + "sh", + "-c", + "kafka-metadata-shell --snapshot /var/lib/kafka/data/__cluster_metadata-0/00000000000000000000.log ls /brokers | wc -l" + ); + String brokers = result.getStdout().replace("\n", ""); + + assertThat(brokers).asInt().isEqualTo(this.brokersNum); + }); + } + + @Override + public void stop() { + this.brokers.parallelStream().forEach(GenericContainer::stop); + } +} diff --git a/examples/kafka-cluster/src/test/resources/logback-test.xml b/examples/kafka-cluster/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/kafka-cluster/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/examples/nats/build.gradle b/examples/nats/build.gradle new file mode 100644 index 00000000000..5e63a2d33a0 --- /dev/null +++ b/examples/nats/build.gradle @@ -0,0 +1,21 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation 'org.assertj:assertj-core:3.27.4' + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'io.nats:jnats:2.23.0' + testImplementation 'ch.qos.logback:logback-classic:1.3.15' + testImplementation 'org.apache.httpcomponents:httpclient:4.5.14' + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.0' +} + +test { + useJUnitPlatform() +} diff --git a/examples/nats/src/test/java/com/example/NatsContainerTest.java b/examples/nats/src/test/java/com/example/NatsContainerTest.java new file mode 100644 index 00000000000..6b6cecb0bdc --- /dev/null +++ b/examples/nats/src/test/java/com/example/NatsContainerTest.java @@ -0,0 +1,56 @@ +package com.example; + +import io.nats.client.Connection; +import io.nats.client.Nats; +import io.nats.client.Options; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.impl.client.HttpClientBuilder; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +class NatsContainerTest { + + public static final Integer NATS_PORT = 4222; + + public static final Integer NATS_MGMT_PORT = 8222; + + @Test + void test() throws IOException, InterruptedException { + try ( + GenericContainer nats = new GenericContainer<>("nats:2.9.8-alpine3.16") + .withExposedPorts(NATS_PORT, NATS_MGMT_PORT) + ) { + nats.start(); + + Connection connection = Nats.connect( + new Options.Builder().server("nats://" + nats.getHost() + ":" + nats.getMappedPort(NATS_PORT)).build() + ); + + assertThat(connection.getStatus()).isEqualTo(Connection.Status.CONNECTED); + } + } + + @Test + void testServerStatus() throws IOException { + try ( + GenericContainer nats = new GenericContainer<>("nats:2.9.8-alpine3.16") + .withExposedPorts(NATS_PORT, NATS_MGMT_PORT) + ) { + nats.start(); + + HttpUriRequest request = new HttpGet( + String.format("http://%s:%d/varz", nats.getHost(), nats.getMappedPort(NATS_MGMT_PORT)) + ); + HttpResponse httpResponse = HttpClientBuilder.create().build().execute(request); + + assertThat(httpResponse.getStatusLine().getStatusCode()).isEqualTo(HttpStatus.SC_OK); + } + } +} diff --git a/examples/nats/src/test/resources/logback-test.xml b/examples/nats/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/nats/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/examples/neo4j-container/build.gradle b/examples/neo4j-container/build.gradle new file mode 100644 index 00000000000..7a6eed0016d --- /dev/null +++ b/examples/neo4j-container/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation 'org.assertj:assertj-core:3.27.4' + testImplementation 'org.neo4j.driver:neo4j-java-driver:4.4.20' + testImplementation 'org.testcontainers:testcontainers-neo4j' + testImplementation 'org.testcontainers:testcontainers-junit-jupiter' + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.0' +} + +test { + useJUnitPlatform() +} diff --git a/examples/neo4j-container/src/test/java/org/testcontainers/containers/Neo4jExampleTest.java b/examples/neo4j-container/src/test/java/org/testcontainers/containers/Neo4jExampleTest.java new file mode 100644 index 00000000000..bc8770aaef0 --- /dev/null +++ b/examples/neo4j-container/src/test/java/org/testcontainers/containers/Neo4jExampleTest.java @@ -0,0 +1,69 @@ +package org.testcontainers.containers; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.AuthTokens; +import org.neo4j.driver.Driver; +import org.neo4j.driver.GraphDatabase; +import org.neo4j.driver.Session; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Collections; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +@Testcontainers +class Neo4jExampleTest { + + @Container + private static Neo4jContainer neo4jContainer = new Neo4jContainer<>(DockerImageName.parse("neo4j:4.4")) + .withoutAuthentication(); // Disable password + + @Test + void testSomethingUsingBolt() { + // Retrieve the Bolt URL from the container + String boltUrl = neo4jContainer.getBoltUrl(); + try (Driver driver = GraphDatabase.driver(boltUrl, AuthTokens.none()); Session session = driver.session()) { + long one = session.run("RETURN 1", Collections.emptyMap()).next().get(0).asLong(); + assertThat(one).isEqualTo(1L); + } catch (Exception e) { + fail(e.getMessage()); + } + } + + @Test + void testSomethingUsingHttp() throws IOException { + // Retrieve the HTTP URL from the container + String httpUrl = neo4jContainer.getHttpUrl(); + + URL url = new URL(httpUrl + "/db/data/transaction/commit"); + HttpURLConnection con = (HttpURLConnection) url.openConnection(); + + con.setRequestMethod("POST"); + con.setRequestProperty("Content-Type", "application/json"); + con.setDoOutput(true); + + try (Writer out = new OutputStreamWriter(con.getOutputStream())) { + out.write("{\"statements\":[{\"statement\":\"RETURN 1\"}]}"); + out.flush(); + } + + assertThat(con.getResponseCode()).isEqualTo(HttpURLConnection.HTTP_OK); + try (BufferedReader buffer = new BufferedReader(new InputStreamReader(con.getInputStream()))) { + String expectedResponse = + "{\"results\":[{\"columns\":[\"1\"],\"data\":[{\"row\":[1],\"meta\":[null]}]}],\"errors\":[]}"; + String response = buffer.lines().collect(Collectors.joining("\n")); + assertThat(response).isEqualTo(expectedResponse); + } + } +} diff --git a/examples/neo4j-container/src/test/resources/logback-test.xml b/examples/neo4j-container/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/neo4j-container/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/examples/ollama-hugging-face/build.gradle b/examples/ollama-hugging-face/build.gradle new file mode 100644 index 00000000000..1d747834acc --- /dev/null +++ b/examples/ollama-hugging-face/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation 'org.testcontainers:testcontainers-ollama' + testImplementation 'org.assertj:assertj-core:3.27.4' + testImplementation 'ch.qos.logback:logback-classic:1.3.15' + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + testImplementation 'io.rest-assured:rest-assured:5.5.6' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.0' +} + +test { + useJUnitPlatform() +} diff --git a/examples/ollama-hugging-face/src/test/java/com/example/ollamahf/OllamaHuggingFaceContainer.java b/examples/ollama-hugging-face/src/test/java/com/example/ollamahf/OllamaHuggingFaceContainer.java new file mode 100644 index 00000000000..58267134a5f --- /dev/null +++ b/examples/ollama-hugging-face/src/test/java/com/example/ollamahf/OllamaHuggingFaceContainer.java @@ -0,0 +1,63 @@ +package com.example.ollamahf; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.ollama.OllamaContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; + +public class OllamaHuggingFaceContainer extends OllamaContainer { + + private final HuggingFaceModel huggingFaceModel; + + public OllamaHuggingFaceContainer(HuggingFaceModel model) { + super(DockerImageName.parse("ollama/ollama:0.1.47")); + this.huggingFaceModel = model; + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo, boolean reused) { + super.containerIsStarted(containerInfo, reused); + if (reused || huggingFaceModel == null) { + return; + } + + try { + executeCommand("apt-get", "update"); + executeCommand("apt-get", "upgrade", "-y"); + executeCommand("apt-get", "install", "-y", "python3-pip"); + executeCommand("pip", "install", "huggingface-hub"); + executeCommand("hf", "download", huggingFaceModel.repository, huggingFaceModel.model, "--local-dir", "."); + executeCommand("sh", "-c", String.format("echo '%s' > Modelfile", huggingFaceModel.modelfileContent)); + executeCommand("ollama", "create", huggingFaceModel.model, "-f", "Modelfile"); + executeCommand("rm", huggingFaceModel.model); + } catch (IOException | InterruptedException e) { + throw new ContainerLaunchException(e.getMessage()); + } + } + + private void executeCommand(String... command) throws ContainerLaunchException, IOException, InterruptedException { + ExecResult execResult = execInContainer(command); + if (execResult.getExitCode() > 0) { + throw new ContainerLaunchException( + "Failed to execute " + String.join(" ", command) + ": " + execResult.getStdout() + ); + } + } + + public static class HuggingFaceModel { + + public final String repository; + + public final String model; + + public String modelfileContent; + + public HuggingFaceModel(String repository, String model) { + this.repository = repository; + this.model = model; + this.modelfileContent = "FROM " + model; + } + } +} diff --git a/examples/ollama-hugging-face/src/test/java/com/example/ollamahf/OllamaHuggingFaceTest.java b/examples/ollama-hugging-face/src/test/java/com/example/ollamahf/OllamaHuggingFaceTest.java new file mode 100644 index 00000000000..fa9d5f7d7e4 --- /dev/null +++ b/examples/ollama-hugging-face/src/test/java/com/example/ollamahf/OllamaHuggingFaceTest.java @@ -0,0 +1,74 @@ +package com.example.ollamahf; + +import io.restassured.http.Header; +import org.junit.jupiter.api.Test; +import org.testcontainers.ollama.OllamaContainer; +import org.testcontainers.utility.DockerImageName; + +import java.util.List; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; + +public class OllamaHuggingFaceTest { + + @Test + public void embeddingModelWithHuggingFace() { + String repository = "CompendiumLabs/bge-small-en-v1.5-gguf"; + String model = "bge-small-en-v1.5-q4_k_m.gguf"; + String imageName = "embedding-model-from-hugging-face"; + OllamaContainer ollama = new OllamaContainer( + DockerImageName.parse(imageName).asCompatibleSubstituteFor("ollama/ollama:0.1.47") + ); + boolean imageExists = ollama + .getDockerClient() + .listImagesCmd() + .exec() + .stream() + .anyMatch(image -> image.getRepoTags()[0].equals(imageName + ":latest")); + if (!imageExists) { + createImage(imageName, repository, model); + } + ollama.start(); + + String modelName = given() + .baseUri(ollama.getEndpoint()) + .get("/api/tags") + .jsonPath() + .getString("models[0].name"); + assertThat(modelName).contains(model + ":latest"); + + List embedding = given() + .baseUri(ollama.getEndpoint()) + .header(new Header("Content-Type", "application/json")) + .body(new EmbeddingRequest(model + ":latest", "Hello from Testcontainers!")) + .post("/api/embeddings") + .jsonPath() + .getList("embedding"); + + assertThat(embedding).isNotNull(); + assertThat(embedding.isEmpty()).isFalse(); + } + + private static void createImage(String imageName, String repository, String model) { + OllamaHuggingFaceContainer.HuggingFaceModel hfModel = new OllamaHuggingFaceContainer.HuggingFaceModel( + repository, + model + ); + OllamaHuggingFaceContainer huggingFaceContainer = new OllamaHuggingFaceContainer(hfModel); + huggingFaceContainer.start(); + huggingFaceContainer.commitToImage(imageName); + } + + public static class EmbeddingRequest { + + public final String model; + + public final String prompt; + + public EmbeddingRequest(String model, String prompt) { + this.model = model; + this.prompt = prompt; + } + } +} diff --git a/examples/ollama-hugging-face/src/test/resources/logback-test.xml b/examples/ollama-hugging-face/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/ollama-hugging-face/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/examples/redis-backed-cache-testng/build.gradle b/examples/redis-backed-cache-testng/build.gradle new file mode 100644 index 00000000000..f0d043ce978 --- /dev/null +++ b/examples/redis-backed-cache-testng/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + compileOnly 'org.slf4j:slf4j-api:1.7.36' + implementation 'redis.clients:jedis:6.2.0' + implementation 'com.google.code.gson:gson:2.13.2' + implementation 'com.google.guava:guava:23.0' + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'ch.qos.logback:logback-classic:1.3.15' + testImplementation 'org.testng:testng:7.5.1' + testImplementation 'org.assertj:assertj-core:3.27.4' +} + +test { + useTestNG() +} diff --git a/shade-test/clashing-deps-jackson/src/main/java/clashingdeps/jackson/cache/Cache.java b/examples/redis-backed-cache-testng/src/main/java/com/mycompany/cache/Cache.java similarity index 96% rename from shade-test/clashing-deps-jackson/src/main/java/clashingdeps/jackson/cache/Cache.java rename to examples/redis-backed-cache-testng/src/main/java/com/mycompany/cache/Cache.java index d1326e7c46d..f0c7710b355 100644 --- a/shade-test/clashing-deps-jackson/src/main/java/clashingdeps/jackson/cache/Cache.java +++ b/examples/redis-backed-cache-testng/src/main/java/com/mycompany/cache/Cache.java @@ -1,4 +1,4 @@ -package clashingdeps.jackson.cache; +package com.mycompany.cache; import java.util.Optional; @@ -6,7 +6,6 @@ * Cache, for storing data associated with keys. */ public interface Cache { - /** * Store a value object in the cache with no specific expiry time. The object may be evicted by the cache any time, * if necessary. @@ -24,4 +23,4 @@ public interface Cache { * @return the object if it was in the cache, or an empty Optional if not found. */ Optional get(String key, Class expectedClass); -} \ No newline at end of file +} diff --git a/examples/redis-backed-cache-testng/src/main/java/com/mycompany/cache/RedisBackedCache.java b/examples/redis-backed-cache-testng/src/main/java/com/mycompany/cache/RedisBackedCache.java new file mode 100644 index 00000000000..8349de77869 --- /dev/null +++ b/examples/redis-backed-cache-testng/src/main/java/com/mycompany/cache/RedisBackedCache.java @@ -0,0 +1,41 @@ +package com.mycompany.cache; + +import com.google.gson.Gson; +import redis.clients.jedis.Jedis; + +import java.util.Optional; + +/** + * An implementation of {@link Cache} that stores data in Redis. + */ +public class RedisBackedCache implements Cache { + + private final Jedis jedis; + + private final String cacheName; + + private final Gson gson; + + public RedisBackedCache(Jedis jedis, String cacheName) { + this.jedis = jedis; + this.cacheName = cacheName; + this.gson = new Gson(); + } + + @Override + public void put(String key, Object value) { + String jsonValue = gson.toJson(value); + this.jedis.hset(this.cacheName, key, jsonValue); + } + + @Override + public Optional get(String key, Class expectedClass) { + String foundJson = this.jedis.hget(this.cacheName, key); + + if (foundJson == null) { + return Optional.empty(); + } + + return Optional.of(gson.fromJson(foundJson, expectedClass)); + } +} diff --git a/examples/redis-backed-cache-testng/src/test/java/RedisBackedCacheTest.java b/examples/redis-backed-cache-testng/src/test/java/RedisBackedCacheTest.java new file mode 100644 index 00000000000..0f97be3c336 --- /dev/null +++ b/examples/redis-backed-cache-testng/src/test/java/RedisBackedCacheTest.java @@ -0,0 +1,61 @@ +import com.mycompany.cache.Cache; +import com.mycompany.cache.RedisBackedCache; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import redis.clients.jedis.Jedis; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for Redis-backed cache implementation. + */ +public class RedisBackedCacheTest { + + private static GenericContainer redis = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) + .withExposedPorts(6379); + + private Cache cache; + + @BeforeClass + public static void startContainer() { + redis.start(); + } + + @AfterClass + public static void stopContainer() { + redis.stop(); + } + + @BeforeMethod + public void setUp() { + Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379)); + + cache = new RedisBackedCache(jedis, "test"); + } + + @Test + public void testFindingAnInsertedValue() { + cache.put("foo", "FOO"); + Optional foundObject = cache.get("foo", String.class); + + assertThat(foundObject).as("When an object in the cache is retrieved, it can be found").isPresent(); + assertThat(foundObject) + .as("When we put a String in to the cache and retrieve it, the value is the same") + .contains("FOO"); + } + + @Test + public void testNotFindingAValueThatWasNotInserted() { + Optional foundObject = cache.get("bar", String.class); + + assertThat(foundObject) + .as("When an object that's not in the cache is retrieved, nothing is found") + .isNotPresent(); + } +} diff --git a/examples/redis-backed-cache-testng/src/test/resources/logback-test.xml b/examples/redis-backed-cache-testng/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/redis-backed-cache-testng/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/examples/redis-backed-cache/build.gradle b/examples/redis-backed-cache/build.gradle new file mode 100644 index 00000000000..5d20b6175dc --- /dev/null +++ b/examples/redis-backed-cache/build.gradle @@ -0,0 +1,24 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + compileOnly 'org.slf4j:slf4j-api:1.7.36' + implementation 'redis.clients:jedis:6.2.0' + implementation 'com.google.code.gson:gson:2.13.2' + implementation 'com.google.guava:guava:23.0' + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.testcontainers:testcontainers-junit-jupiter' + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + testImplementation 'ch.qos.logback:logback-classic:1.3.15' + testImplementation 'org.assertj:assertj-core:3.27.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.0' +} + +test { + useJUnitPlatform() +} diff --git a/examples/redis-backed-cache/src/main/java/com/mycompany/cache/Cache.java b/examples/redis-backed-cache/src/main/java/com/mycompany/cache/Cache.java new file mode 100644 index 00000000000..f0c7710b355 --- /dev/null +++ b/examples/redis-backed-cache/src/main/java/com/mycompany/cache/Cache.java @@ -0,0 +1,26 @@ +package com.mycompany.cache; + +import java.util.Optional; + +/** + * Cache, for storing data associated with keys. + */ +public interface Cache { + /** + * Store a value object in the cache with no specific expiry time. The object may be evicted by the cache any time, + * if necessary. + * + * @param key key that may be used to retrieve the object in the future + * @param value the value object to be stored + */ + void put(String key, Object value); + + /** + * Retrieve a value object from the cache. + * @param key the key that was used to insert the object initially + * @param expectedClass for convenience, a class that the object should be cast to before being returned + * @param the class of the returned object + * @return the object if it was in the cache, or an empty Optional if not found. + */ + Optional get(String key, Class expectedClass); +} diff --git a/examples/redis-backed-cache/src/main/java/com/mycompany/cache/RedisBackedCache.java b/examples/redis-backed-cache/src/main/java/com/mycompany/cache/RedisBackedCache.java new file mode 100644 index 00000000000..8349de77869 --- /dev/null +++ b/examples/redis-backed-cache/src/main/java/com/mycompany/cache/RedisBackedCache.java @@ -0,0 +1,41 @@ +package com.mycompany.cache; + +import com.google.gson.Gson; +import redis.clients.jedis.Jedis; + +import java.util.Optional; + +/** + * An implementation of {@link Cache} that stores data in Redis. + */ +public class RedisBackedCache implements Cache { + + private final Jedis jedis; + + private final String cacheName; + + private final Gson gson; + + public RedisBackedCache(Jedis jedis, String cacheName) { + this.jedis = jedis; + this.cacheName = cacheName; + this.gson = new Gson(); + } + + @Override + public void put(String key, Object value) { + String jsonValue = gson.toJson(value); + this.jedis.hset(this.cacheName, key, jsonValue); + } + + @Override + public Optional get(String key, Class expectedClass) { + String foundJson = this.jedis.hget(this.cacheName, key); + + if (foundJson == null) { + return Optional.empty(); + } + + return Optional.of(gson.fromJson(foundJson, expectedClass)); + } +} diff --git a/examples/redis-backed-cache/src/test/java/RedisBackedCacheTest.java b/examples/redis-backed-cache/src/test/java/RedisBackedCacheTest.java new file mode 100644 index 00000000000..970fc0a58e5 --- /dev/null +++ b/examples/redis-backed-cache/src/test/java/RedisBackedCacheTest.java @@ -0,0 +1,53 @@ +import com.mycompany.cache.Cache; +import com.mycompany.cache.RedisBackedCache; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import redis.clients.jedis.Jedis; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for Redis-backed cache implementation. + */ +@Testcontainers +class RedisBackedCacheTest { + + @Container + public GenericContainer redis = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) + .withExposedPorts(6379); + + private Cache cache; + + @BeforeEach + void setUp() throws Exception { + Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379)); + + cache = new RedisBackedCache(jedis, "test"); + } + + @Test + void testFindingAnInsertedValue() { + cache.put("foo", "FOO"); + Optional foundObject = cache.get("foo", String.class); + + assertThat(foundObject.isPresent()).as("When an object in the cache is retrieved, it can be found").isTrue(); + assertThat(foundObject.get()) + .as("When we put a String in to the cache and retrieve it, the value is the same") + .isEqualTo("FOO"); + } + + @Test + void testNotFindingAValueThatWasNotInserted() { + Optional foundObject = cache.get("bar", String.class); + + assertThat(foundObject.isPresent()) + .as("When an object that's not in the cache is retrieved, nothing is found") + .isFalse(); + } +} diff --git a/examples/redis-backed-cache/src/test/resources/logback-test.xml b/examples/redis-backed-cache/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/redis-backed-cache/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/examples/selenium-container/build.gradle b/examples/selenium-container/build.gradle new file mode 100644 index 00000000000..f1a8058e304 --- /dev/null +++ b/examples/selenium-container/build.gradle @@ -0,0 +1,24 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.6' +} +apply plugin: 'io.spring.dependency-management' + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.seleniumhq.selenium:selenium-remote-driver' + implementation 'org.seleniumhq.selenium:selenium-firefox-driver' + implementation 'org.seleniumhq.selenium:selenium-chrome-driver' + implementation 'org.springframework.boot:spring-boot-starter-web' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.testcontainers:testcontainers-selenium' + testImplementation 'org.testcontainers:testcontainers-junit-jupiter' + testImplementation 'org.assertj:assertj-core:3.27.4' +} + +test { + useJUnitPlatform() +} diff --git a/examples/selenium-container/src/main/java/com/example/DemoApplication.java b/examples/selenium-container/src/main/java/com/example/DemoApplication.java new file mode 100644 index 00000000000..6c42baef27d --- /dev/null +++ b/examples/selenium-container/src/main/java/com/example/DemoApplication.java @@ -0,0 +1,12 @@ +package com.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DemoApplication { + + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } +} diff --git a/examples/selenium-container/src/main/resources/static/foo.html b/examples/selenium-container/src/main/resources/static/foo.html new file mode 100644 index 00000000000..bab56ecb35f --- /dev/null +++ b/examples/selenium-container/src/main/resources/static/foo.html @@ -0,0 +1,10 @@ + + + + Getting Started: Serving Web Content + + + +Hello World + + \ No newline at end of file diff --git a/examples/selenium-container/src/test/java/SeleniumContainerTest.java b/examples/selenium-container/src/test/java/SeleniumContainerTest.java new file mode 100644 index 00000000000..985f2ef8966 --- /dev/null +++ b/examples/selenium-container/src/test/java/SeleniumContainerTest.java @@ -0,0 +1,66 @@ +import com.example.DemoApplication; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.web.context.WebServerInitializedEvent; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ApplicationListener; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.testcontainers.Testcontainers; +import org.testcontainers.containers.BrowserWebDriverContainer; +import org.testcontainers.containers.BrowserWebDriverContainer.VncRecordingMode; +import org.testcontainers.junit.jupiter.Container; + +import java.io.File; +import java.time.Duration; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Simple example of plain Selenium usage. + */ +@org.testcontainers.junit.jupiter.Testcontainers +@ExtendWith(SpringExtension.class) +@SpringBootTest(classes = DemoApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ContextConfiguration(initializers = SeleniumContainerTest.Initializer.class) +class SeleniumContainerTest { + + @LocalServerPort + private int port; + + @Container + public BrowserWebDriverContainer chrome = new BrowserWebDriverContainer() + .withCapabilities(new ChromeOptions()) + .withRecordingMode(VncRecordingMode.RECORD_ALL, new File("build")); + + @Test + void simplePlainSeleniumTest() { + RemoteWebDriver driver = new RemoteWebDriver(chrome.getSeleniumAddress(), new ChromeOptions()); + driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(30)); + + driver.get("http://host.testcontainers.internal:" + port + "/foo.html"); + List hElement = driver.findElements(By.tagName("h")); + + assertThat(hElement).as("The h element is found").isNotEmpty(); + } + + static class Initializer implements ApplicationContextInitializer { + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + applicationContext.addApplicationListener( + (ApplicationListener) event -> { + Testcontainers.exposeHostPorts(event.getWebServer().getPort()); + } + ); + } + } +} diff --git a/examples/selenium-container/src/test/resources/logback-test.xml b/examples/selenium-container/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/selenium-container/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/examples/settings.gradle b/examples/settings.gradle new file mode 100644 index 00000000000..b9b58a917bf --- /dev/null +++ b/examples/settings.gradle @@ -0,0 +1,60 @@ +buildscript { + repositories { + maven { + url "https://plugins.gradle.org/m2/" + } + } + dependencies { + classpath "com.gradle.enterprise:com.gradle.enterprise.gradle.plugin:3.17.4" + classpath "com.gradle:common-custom-user-data-gradle-plugin:2.0.1" + } +} + +apply plugin: 'com.gradle.develocity' +apply plugin: "com.gradle.common-custom-user-data-gradle-plugin" + +rootProject.name = 'testcontainers-examples' + +includeBuild '..' + +// explicit include to allow Dependabot to autodiscover subprojects +include 'kafka-cluster' +include 'neo4j-container' +include 'redis-backed-cache' +include 'redis-backed-cache-testng' +include 'selenium-container' +include 'singleton-container' +include 'solr-container' +include 'spring-boot' +include 'cucumber' +include 'spring-boot-kotlin-redis' +include 'immudb' +include 'zookeeper' +include 'hazelcast' +include 'nats' +include 'sftp' +include 'ollama-hugging-face' + +ext.isCI = System.getenv("CI") != null + +buildCache { + local { + enabled = !isCI + } + remote(develocity.buildCache) { + push = isCI && !System.getenv("READ_ONLY_REMOTE_GRADLE_CACHE") && System.getenv("DEVELOCITY_ACCESS_KEY") + enabled = true + } +} + +develocity { + buildScan { + server = "https://ge.testcontainers.org/" + publishing.onlyIf { + it.authenticated + } + uploadInBackground = !isCI + capture.fileFingerprints = true + } + +} diff --git a/examples/sftp/build.gradle b/examples/sftp/build.gradle new file mode 100644 index 00000000000..d7e2dee0f45 --- /dev/null +++ b/examples/sftp/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation 'com.github.mwiede:jsch:2.27.2' + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.assertj:assertj-core:3.27.4' + testImplementation 'ch.qos.logback:logback-classic:1.3.15' + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.0' +} + +test { + useJUnitPlatform() +} diff --git a/examples/sftp/src/test/java/org/example/SftpContainerTest.java b/examples/sftp/src/test/java/org/example/SftpContainerTest.java new file mode 100644 index 00000000000..3a6593ea736 --- /dev/null +++ b/examples/sftp/src/test/java/org/example/SftpContainerTest.java @@ -0,0 +1,105 @@ +package org.example; + +import com.jcraft.jsch.ChannelSftp; +import com.jcraft.jsch.HostKey; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.Session; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.MountableFile; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +class SftpContainerTest { + + @Test + void test() throws Exception { + try ( + GenericContainer sftp = new GenericContainer<>("atmoz/sftp:alpine-3.7") + .withCopyFileToContainer( + MountableFile.forClasspathResource("testcontainers/", 0777), + "/home/foo/upload/testcontainers" + ) + .withExposedPorts(22) + .withCommand("foo:pass:::upload") + ) { + sftp.start(); + JSch jsch = new JSch(); + Session jschSession = jsch.getSession("foo", sftp.getHost(), sftp.getMappedPort(22)); + jschSession.setPassword("pass"); + jschSession.setConfig("StrictHostKeyChecking", "no"); + jschSession.connect(); + ChannelSftp channel = (ChannelSftp) jschSession.openChannel("sftp"); + channel.connect(); + assertThat(channel.ls("/upload/testcontainers")).anyMatch(item -> item.toString().contains("file.txt")); + assertThat( + new BufferedReader( + new InputStreamReader(channel.get("/upload/testcontainers/file.txt"), StandardCharsets.UTF_8) + ) + .lines() + .collect(Collectors.joining("\n")) + ) + .contains("Testcontainers"); + channel.rm("/upload/testcontainers/file.txt"); + assertThat(channel.ls("/upload/testcontainers/")) + .noneMatch(item -> item.toString().contains("testcontainers/file.txt")); + } + } + + @Test + void testHostKeyCheck() throws Exception { + try ( + GenericContainer sftp = new GenericContainer<>("atmoz/sftp:alpine-3.7") + .withCopyFileToContainer( + MountableFile.forClasspathResource("testcontainers/", 0777), + "/home/foo/upload/testcontainers" + ) + .withCopyFileToContainer( + MountableFile.forClasspathResource("./ssh_host_rsa_key", 0400), + "/etc/ssh/ssh_host_rsa_key" + ) + .withExposedPorts(22) + .withCommand("foo:pass:::upload") + ) { + sftp.start(); + JSch jsch = new JSch(); + Session jschSession = jsch.getSession("foo", sftp.getHost(), sftp.getMappedPort(22)); + jschSession.setPassword("pass"); + // hostKeyString is string starting with AAAA from file known_hosts or ssh_host_*_key.pub + // generate the files with: + // ssh-keygen -t rsa -b 3072 -f ssh_host_rsa_key < /dev/null + String hostKeyString = + "AAAAB3NzaC1yc2EAAAADAQABAAABgQCXMxVRzmFWxfrRB9XiZ/3HNM+xkYYE+IMGuOZD" + + "04M2ezU25XjT6cPajzpFmzTxR2qEpRCKHeVnSG5nT6UXQp7760brTN7m5sDasbMnHgYh" + + "fC/3of2k6qTR9X/JHRpgwzq5+6FtEe41w1H1dXoNIr4YTKnLijSp8MKqBtPPNUpzEVb9" + + "5YKZGdCDoCbbYOyS/Dc8azUDo0mqM542J3nA2Sq9HCP0BAv43hrTAtCZodkB5wo18exb" + + "fPKsjGtA3de2npybFoSRbavZmT8L/b2iHZX6FRaqLsbYGKtszCWu5OU7WBX5g5QVlLfO" + + "nGQ+LsF6d6pX5LlMwEU14uu4gNPvZFOaZXtHNHZqnBcjd/sMaw5N/atFsPgtQ0vYnrEA" + + "D6oDjj0uXMsnmgUWTZBi3q2GBWWPqhE+0ASb2xBQGa+tWWTVYbuuYlA7hUX0URK8FcLw" + + "4UOYJjscDjnjlvQkghd2esP5NxV1NXkG2XYNHnf1E/tH4+AHJzy+qOQom7ehda96FZ8="; + HostKey hostKey = new HostKey(sftp.getHost(), Base64.getDecoder().decode(hostKeyString)); + jschSession.getHostKeyRepository().add(hostKey, null); + jschSession.connect(); + ChannelSftp channel = (ChannelSftp) jschSession.openChannel("sftp"); + channel.connect(); + assertThat(channel.ls("/upload/testcontainers")).anyMatch(item -> item.toString().contains("file.txt")); + assertThat( + new BufferedReader( + new InputStreamReader(channel.get("/upload/testcontainers/file.txt"), StandardCharsets.UTF_8) + ) + .lines() + .collect(Collectors.joining("\n")) + ) + .contains("Testcontainers"); + channel.rm("/upload/testcontainers/file.txt"); + assertThat(channel.ls("/upload/testcontainers/")) + .noneMatch(item -> item.toString().contains("testcontainers/file.txt")); + } + } +} diff --git a/examples/sftp/src/test/resources/logback-test.xml b/examples/sftp/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/sftp/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/examples/sftp/src/test/resources/ssh_host_rsa_key b/examples/sftp/src/test/resources/ssh_host_rsa_key new file mode 100644 index 00000000000..9987990b63d --- /dev/null +++ b/examples/sftp/src/test/resources/ssh_host_rsa_key @@ -0,0 +1,38 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn +NhAAAAAwEAAQAAAYEAlzMVUc5hVsX60QfV4mf9xzTPsZGGBPiDBrjmQ9ODNns1NuV40+nD +2o86RZs08UdqhKUQih3lZ0huZ0+lF0Ke++tG60ze5ubA2rGzJx4GIXwv96H9pOqk0fV/yR +0aYMM6ufuhbRHuNcNR9XV6DSK+GEypy4o0qfDCqgbTzzVKcxFW/eWCmRnQg6Am22Dskvw3 +PGs1A6NJqjOeNid5wNkqvRwj9AQL+N4a0wLQmaHZAecKNfHsW3zyrIxrQN3Xtp6cmxaEkW +2r2Zk/C/29oh2V+hUWqi7G2BirbMwlruTlO1gV+YOUFZS3zpxkPi7BeneqV+S5TMBFNeLr +uIDT72RTmmV7RzR2apwXI3f7DGsOTf2rRbD4LUNL2J6xAA+qA449LlzLJ5oFFk2QYt6thg +Vlj6oRPtAEm9sQUBmvrVlk1WG7rmJQO4VF9FESvBXC8OFDmCY7HA4545b0JIIXdnrD+TcV +dTV5Btl2DR539RP7R+PgByc8vqjkKJu3oXWvehWfAAAFiPUCzjT1As40AAAAB3NzaC1yc2 +EAAAGBAJczFVHOYVbF+tEH1eJn/cc0z7GRhgT4gwa45kPTgzZ7NTbleNPpw9qPOkWbNPFH +aoSlEIod5WdIbmdPpRdCnvvrRutM3ubmwNqxsyceBiF8L/eh/aTqpNH1f8kdGmDDOrn7oW +0R7jXDUfV1eg0ivhhMqcuKNKnwwqoG0881SnMRVv3lgpkZ0IOgJttg7JL8NzxrNQOjSaoz +njYnecDZKr0cI/QEC/jeGtMC0Jmh2QHnCjXx7Ft88qyMa0Dd17aenJsWhJFtq9mZPwv9va +IdlfoVFqouxtgYq2zMJa7k5TtYFfmDlBWUt86cZD4uwXp3qlfkuUzARTXi67iA0+9kU5pl +e0c0dmqcFyN3+wxrDk39q0Ww+C1DS9iesQAPqgOOPS5cyyeaBRZNkGLerYYFZY+qET7QBJ +vbEFAZr61ZZNVhu65iUDuFRfRRErwVwvDhQ5gmOxwOOeOW9CSCF3Z6w/k3FXU1eQbZdg0e +d/UT+0fj4AcnPL6o5Cibt6F1r3oVnwAAAAMBAAEAAAGALcv8wKcUx6423tqTN70M2qpN4H +h2Egpd0YruwAuQWk+uWh7eXr2XI5uvaEbvHcfmZSAEJvmQMxz2x9cRZ763nhFxDTNe7qxl +LLiXTZlj/P97HfQUej/SRYApQPbONxHbN1sW1Y0RTHqJWCJJojHsRzrtUSfe9Lxmkg54WH +JJRxow8b1zNcFibYP0UQ2GCq1XY7cLOztZxDJXUQra74U300jzQOV65NoNYO2g1m/15YQg +DR/mWf26GXZ8xAyN2pQm3wiI86kY1UP+2kVr38tGcJ+Xrm08Pav06IiEUdFAdDRLL0AWXY +ZG25BBJn2VaPZoE5+MH7xRQ2BrqNUZ6ec8jTPZXWN6VyZCmn06KRblIRnv/NcMV5GH/lE9 +JbP/MnQQzsQAO0REfhcrdb66I6l0jMTwQcvSJyPXLVl1UvobzcF+CpcExsoaQj5U9cwhkG +XRLqPhI76+L0L2kNefQ4yN5MhxWiajKUOknRITkvmNR+jJYsUN/ziODRevbakBzyqtAAAA +wCpC6P+iJg19HdhNf6I2IUQErPoltUhA5bsUGmuseCn19Y3V5RmNa8+HHfbnMkUSoFzTvS +j0l7rkxl0vvPmz0zr/2ehWiMbReFRy3hGl55AGPLE7pjIy08JIUcQm2jH8C3oeSKNwCrYV ++HWsOsQu4+/uOTgp6I46+iSLLG+xjH+5zLtvxa6+o+zLjAOSW4aweAw1WAXy8J4ylAv2nA +n3g3Rfa7C0qZG1bZ63phcgv2BNzN+QgmORoh5v5ICvT+qJ5wAAAMEAwvdI3XsLV0uzNkAq +C9aWyK4cAdphvCb8n0oz5Vrm6j/qFRXzcDZLtkMboCRE2qVqNLQjMiTJo/QjX9jxe7LD6c +Vxtlcl2Ts8qrixFhKXJNwC/lq/TTe2dpMSYm61OINK3TiofZi6eff/ubcpq7zr3iVyWk5b +wAVSun8q+Su7ziYYb+MuBQsKn5VWyoYK+E/LFItY26ulOxbrntB805JsXpjbYrL0KoXJCx +6ZWdBVsvbD733WipNbPQZ+4JYDbun7AAAAwQDGiFOALlS5nidWFqMeMm/dGsHpwri0b10Z +Bf/DPPxK6EuFKLUppt6KMl2zJjwVa2NqSTppz7TpUP6jC5pSglxtcvatEIRVF8KBxuIJ/G +8Wav3Xuxu9nrRyKAzXjrjU+4TjAH1jBfTj3/tDdRagxt7JESirE+sYW5nie9XpzW4ehsf6 +fJacmwoiGdSCc4dldD8ZkEXcmCChFTH+PY3uYtiJr+znzbUZ1RLL3Uk2xHWOWSHz/1tUBy +BFP58e3rYvNa0AAAAPYWFAMjMtMDcxNTMtMDA5AQIDBA== +-----END OPENSSH PRIVATE KEY----- diff --git a/examples/sftp/src/test/resources/ssh_host_rsa_key.pub b/examples/sftp/src/test/resources/ssh_host_rsa_key.pub new file mode 100644 index 00000000000..57b3aebb050 --- /dev/null +++ b/examples/sftp/src/test/resources/ssh_host_rsa_key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCXMxVRzmFWxfrRB9XiZ/3HNM+xkYYE+IMGuOZD04M2ezU25XjT6cPajzpFmzTxR2qEpRCKHeVnSG5nT6UXQp7760brTN7m5sDasbMnHgYhfC/3of2k6qTR9X/JHRpgwzq5+6FtEe41w1H1dXoNIr4YTKnLijSp8MKqBtPPNUpzEVb95YKZGdCDoCbbYOyS/Dc8azUDo0mqM542J3nA2Sq9HCP0BAv43hrTAtCZodkB5wo18exbfPKsjGtA3de2npybFoSRbavZmT8L/b2iHZX6FRaqLsbYGKtszCWu5OU7WBX5g5QVlLfOnGQ+LsF6d6pX5LlMwEU14uu4gNPvZFOaZXtHNHZqnBcjd/sMaw5N/atFsPgtQ0vYnrEAD6oDjj0uXMsnmgUWTZBi3q2GBWWPqhE+0ASb2xBQGa+tWWTVYbuuYlA7hUX0URK8FcLw4UOYJjscDjnjlvQkghd2esP5NxV1NXkG2XYNHnf1E/tH4+AHJzy+qOQom7ehda96FZ8= someone@localhost diff --git a/examples/sftp/src/test/resources/testcontainers/file.txt b/examples/sftp/src/test/resources/testcontainers/file.txt new file mode 100644 index 00000000000..0e40067849a --- /dev/null +++ b/examples/sftp/src/test/resources/testcontainers/file.txt @@ -0,0 +1 @@ +Testcontainers diff --git a/examples/singleton-container/build.gradle b/examples/singleton-container/build.gradle new file mode 100644 index 00000000000..5db92c84a49 --- /dev/null +++ b/examples/singleton-container/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + + implementation 'redis.clients:jedis:6.2.0' + implementation 'com.google.code.gson:gson:2.13.2' + implementation 'com.google.guava:guava:23.0' + compileOnly 'org.slf4j:slf4j-api:1.7.36' + + testImplementation 'ch.qos.logback:logback-classic:1.3.15' + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.assertj:assertj-core:3.27.4' + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.0' +} + +test { + useJUnitPlatform() +} diff --git a/examples/singleton-container/src/main/java/com/example/cache/Cache.java b/examples/singleton-container/src/main/java/com/example/cache/Cache.java new file mode 100644 index 00000000000..8a0c5776d80 --- /dev/null +++ b/examples/singleton-container/src/main/java/com/example/cache/Cache.java @@ -0,0 +1,9 @@ +package com.example.cache; + +import java.util.Optional; + +public interface Cache { + void put(String key, Object value); + + Optional get(String key, Class expectedClass); +} diff --git a/examples/singleton-container/src/main/java/com/example/cache/RedisBackedCache.java b/examples/singleton-container/src/main/java/com/example/cache/RedisBackedCache.java new file mode 100644 index 00000000000..430afb2b1f5 --- /dev/null +++ b/examples/singleton-container/src/main/java/com/example/cache/RedisBackedCache.java @@ -0,0 +1,36 @@ +package com.example.cache; + +import com.google.gson.Gson; +import redis.clients.jedis.Jedis; + +import java.util.Optional; + +public class RedisBackedCache implements Cache { + + private final Jedis jedis; + + private final String cacheName; + + private final Gson gson; + + public RedisBackedCache(Jedis jedis, String cacheName) { + this.jedis = jedis; + this.cacheName = cacheName; + this.gson = new Gson(); + } + + public void put(String key, Object value) { + String jsonValue = gson.toJson(value); + this.jedis.hset(this.cacheName, key, jsonValue); + } + + public Optional get(String key, Class expectedClass) { + String foundJson = this.jedis.hget(this.cacheName, key); + + if (foundJson == null) { + return Optional.empty(); + } + + return Optional.of(gson.fromJson(foundJson, expectedClass)); + } +} diff --git a/examples/singleton-container/src/test/java/com/example/AbstractIntegrationTest.java b/examples/singleton-container/src/test/java/com/example/AbstractIntegrationTest.java new file mode 100644 index 00000000000..654977d7a22 --- /dev/null +++ b/examples/singleton-container/src/test/java/com/example/AbstractIntegrationTest.java @@ -0,0 +1,14 @@ +package com.example; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +public abstract class AbstractIntegrationTest { + + public static final GenericContainer redis = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) + .withExposedPorts(6379); + + static { + redis.start(); + } +} diff --git a/examples/singleton-container/src/test/java/com/example/BarConcreteTestClass.java b/examples/singleton-container/src/test/java/com/example/BarConcreteTestClass.java new file mode 100644 index 00000000000..da7b9a2e1cf --- /dev/null +++ b/examples/singleton-container/src/test/java/com/example/BarConcreteTestClass.java @@ -0,0 +1,34 @@ +package com.example; + +import com.example.cache.Cache; +import com.example.cache.RedisBackedCache; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import redis.clients.jedis.Jedis; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class BarConcreteTestClass extends AbstractIntegrationTest { + + private Cache cache; + + @BeforeEach + void setUp() { + Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379)); + + cache = new RedisBackedCache(jedis, "bar"); + } + + @Test + void testInsertValue() { + cache.put("bar", "BAR"); + Optional foundObject = cache.get("bar", String.class); + + assertThat(foundObject).as("When inserting an object into the cache, it can be retrieved").isPresent(); + assertThat(foundObject) + .as("When accessing the value of a retrieved object, the value must be the same") + .contains("BAR"); + } +} diff --git a/examples/singleton-container/src/test/java/com/example/FooConcreteTestClass.java b/examples/singleton-container/src/test/java/com/example/FooConcreteTestClass.java new file mode 100644 index 00000000000..cd17e3c4ae1 --- /dev/null +++ b/examples/singleton-container/src/test/java/com/example/FooConcreteTestClass.java @@ -0,0 +1,34 @@ +package com.example; + +import com.example.cache.Cache; +import com.example.cache.RedisBackedCache; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import redis.clients.jedis.Jedis; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class FooConcreteTestClass extends AbstractIntegrationTest { + + private Cache cache; + + @BeforeEach + void setUp() { + Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379)); + + cache = new RedisBackedCache(jedis, "foo"); + } + + @Test + void testInsertValue() { + cache.put("foo", "FOO"); + Optional foundObject = cache.get("foo", String.class); + + assertThat(foundObject).as("When inserting an object into the cache, it can be retrieved").isPresent(); + assertThat(foundObject) + .as("When accessing the value of a retrieved object, the value must be the same") + .contains("FOO"); + } +} diff --git a/examples/singleton-container/src/test/resources/logback-test.xml b/examples/singleton-container/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/singleton-container/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/examples/solr-container/build.gradle b/examples/solr-container/build.gradle new file mode 100644 index 00000000000..d0d3944cc0a --- /dev/null +++ b/examples/solr-container/build.gradle @@ -0,0 +1,24 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + compileOnly "org.projectlombok:lombok:1.18.38" + annotationProcessor "org.projectlombok:lombok:1.18.38" + + implementation 'org.apache.solr:solr-solrj:8.11.4' + + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.testcontainers:testcontainers-solr' + testImplementation 'org.assertj:assertj-core:3.27.4' + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.0' +} + +test { + useJUnitPlatform() +} diff --git a/examples/solr-container/src/main/java/com/example/SearchEngine.java b/examples/solr-container/src/main/java/com/example/SearchEngine.java new file mode 100644 index 00000000000..397327fe369 --- /dev/null +++ b/examples/solr-container/src/main/java/com/example/SearchEngine.java @@ -0,0 +1,5 @@ +package com.example; + +public interface SearchEngine { + public SearchResult search(String term); +} diff --git a/examples/solr-container/src/main/java/com/example/SearchResult.java b/examples/solr-container/src/main/java/com/example/SearchResult.java new file mode 100644 index 00000000000..89722d1a5a4 --- /dev/null +++ b/examples/solr-container/src/main/java/com/example/SearchResult.java @@ -0,0 +1,20 @@ +package com.example; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SearchResult { + + private long totalHits; + + private List> results; +} diff --git a/examples/solr-container/src/main/java/com/example/SolrSearchEngine.java b/examples/solr-container/src/main/java/com/example/SolrSearchEngine.java new file mode 100644 index 00000000000..3be96f76690 --- /dev/null +++ b/examples/solr-container/src/main/java/com/example/SolrSearchEngine.java @@ -0,0 +1,35 @@ +package com.example; + +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrQuery; +import org.apache.solr.client.solrj.response.QueryResponse; +import org.apache.solr.client.solrj.util.ClientUtils; +import org.apache.solr.common.SolrDocument; + +import java.util.stream.Collectors; + +@RequiredArgsConstructor +public class SolrSearchEngine implements SearchEngine { + + public static final String COLLECTION_NAME = "products"; + + private final SolrClient client; + + @SneakyThrows + public SearchResult search(String term) { + SolrQuery query = new SolrQuery(); + query.setQuery("title:" + ClientUtils.escapeQueryChars(term)); + QueryResponse response = client.query(COLLECTION_NAME, query); + return createResult(response); + } + + private SearchResult createResult(QueryResponse response) { + return SearchResult + .builder() + .totalHits(response.getResults().getNumFound()) + .results(response.getResults().stream().map(SolrDocument::getFieldValueMap).collect(Collectors.toList())) + .build(); + } +} diff --git a/examples/solr-container/src/test/java/com/example/SolrQueryTest.java b/examples/solr-container/src/test/java/com/example/SolrQueryTest.java new file mode 100644 index 00000000000..e8bb6876a92 --- /dev/null +++ b/examples/solr-container/src/test/java/com/example/SolrQueryTest.java @@ -0,0 +1,103 @@ +package com.example; + +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrServerException; +import org.apache.solr.client.solrj.impl.Http2SolrClient; +import org.apache.solr.common.SolrInputDocument; +import org.apache.solr.common.SolrInputField; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.SolrContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class SolrQueryTest { + + private static final DockerImageName SOLR_IMAGE = DockerImageName.parse("solr:8.3.0"); + + public static final SolrContainer solrContainer = new SolrContainer(SOLR_IMAGE) + .withCollection(SolrSearchEngine.COLLECTION_NAME); + + private static SolrClient solrClient; + + @BeforeAll + static void setUp() throws IOException, SolrServerException { + solrContainer.start(); + solrClient = + new Http2SolrClient.Builder( + "http://" + solrContainer.getHost() + ":" + solrContainer.getSolrPort() + "/solr" + ) + .build(); + + // Add Sample Data + solrClient.add( + SolrSearchEngine.COLLECTION_NAME, + Collections.singletonList( + new SolrInputDocument( + createMap( + "id", + createInputField("id", "1"), + "title", + createInputField("title", "old skool - trainers - shoes") + ) + ) + ) + ); + + solrClient.add( + SolrSearchEngine.COLLECTION_NAME, + Collections.singletonList( + new SolrInputDocument( + createMap("id", createInputField("id", "2"), "title", createInputField("title", "print t-shirt")) + ) + ) + ); + + solrClient.commit(SolrSearchEngine.COLLECTION_NAME); + } + + @Test + void testQueryForShoes() { + SolrSearchEngine searchEngine = new SolrSearchEngine(solrClient); + + SearchResult result = searchEngine.search("shoes"); + assertThat(result.getTotalHits()).as("When searching for shoes we expect one result").isEqualTo(1L); + assertThat(result.getResults().get(0).get("id")).as("The result should have the id 1").isEqualTo("1"); + } + + @Test + void testQueryForTShirt() { + SolrSearchEngine searchEngine = new SolrSearchEngine(solrClient); + + SearchResult result = searchEngine.search("t-shirt"); + assertThat(result.getTotalHits()).as("When searching for t-shirt we expect one result").isEqualTo(1L); + assertThat(result.getResults().get(0).get("id")).as("The result should have the id 2").isEqualTo("2"); + } + + @Test + void testQueryForAsterisk() { + SolrSearchEngine searchEngine = new SolrSearchEngine(solrClient); + + SearchResult result = searchEngine.search("*"); + assertThat(result.getTotalHits()).as("When searching for * we expect no results").isEqualTo(0L); + } + + private static SolrInputField createInputField(String key, String value) { + SolrInputField inputField = new SolrInputField(key); + inputField.setValue(value); + return inputField; + } + + private static Map createMap(String k0, SolrInputField v0, String k1, SolrInputField v1) { + Map result = new HashMap<>(); + result.put(k0, v0); + result.put(k1, v1); + return result; + } +} diff --git a/examples/solr-container/src/test/resources/logback-test.xml b/examples/solr-container/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/solr-container/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/examples/spring-boot-kotlin-redis/build.gradle.kts b/examples/spring-boot-kotlin-redis/build.gradle.kts new file mode 100644 index 00000000000..2d5d6bf9640 --- /dev/null +++ b/examples/spring-boot-kotlin-redis/build.gradle.kts @@ -0,0 +1,36 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("org.springframework.boot") version "2.7.18" + kotlin("jvm") version "1.8.22" + kotlin("plugin.spring") version "1.8.22" +} + +java.sourceCompatibility = JavaVersion.VERSION_1_8 + +repositories { + mavenCentral() +} + +dependencies { + apply(plugin = "io.spring.dependency-management") + implementation("org.springframework.boot:spring-boot-starter") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + implementation("org.springframework.boot:spring-boot-starter-data-redis") + implementation("org.springframework.boot:spring-boot-starter-web") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.testcontainers:testcontainers") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.8.2") +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs = listOf("-Xjsr305=strict") + jvmTarget = "1.8" + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/examples/spring-boot-kotlin-redis/src/main/kotlin/com/example/redis/ExampleController.kt b/examples/spring-boot-kotlin-redis/src/main/kotlin/com/example/redis/ExampleController.kt new file mode 100644 index 00000000000..b890c301242 --- /dev/null +++ b/examples/spring-boot-kotlin-redis/src/main/kotlin/com/example/redis/ExampleController.kt @@ -0,0 +1,27 @@ +package com.example.redis + +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController + +@RestController +class ExampleController( + private val redisTemplate: RedisTemplate +) { + + companion object { + const val key = "testcontainers" + } + + @PostMapping("/set-foo") + fun setFoo(@RequestBody value: String) { + redisTemplate.opsForValue().set(key, value) + } + + @GetMapping("/get-foo") + fun getFoo(): String? { + return redisTemplate.opsForValue().get(key) + } +} diff --git a/examples/spring-boot-kotlin-redis/src/main/kotlin/com/example/redis/RedisApplication.kt b/examples/spring-boot-kotlin-redis/src/main/kotlin/com/example/redis/RedisApplication.kt new file mode 100644 index 00000000000..dc06c8004bf --- /dev/null +++ b/examples/spring-boot-kotlin-redis/src/main/kotlin/com/example/redis/RedisApplication.kt @@ -0,0 +1,11 @@ +package com.example.redis + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class RedisApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/examples/spring-boot-kotlin-redis/src/test/kotlin/com/example/redis/AbstractIntegrationTest.kt b/examples/spring-boot-kotlin-redis/src/test/kotlin/com/example/redis/AbstractIntegrationTest.kt new file mode 100644 index 00000000000..6df81f56262 --- /dev/null +++ b/examples/spring-boot-kotlin-redis/src/test/kotlin/com/example/redis/AbstractIntegrationTest.kt @@ -0,0 +1,33 @@ +package com.example.redis + +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.util.TestPropertyValues +import org.springframework.context.ApplicationContextInitializer +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.testcontainers.containers.GenericContainer + +@SpringBootTest +@ContextConfiguration(initializers = [AbstractIntegrationTest.Initializer::class]) +@AutoConfigureMockMvc +abstract class AbstractIntegrationTest { + + companion object { + val redisContainer = GenericContainer("redis:6-alpine") + .apply { withExposedPorts(6379) } + } + + internal class Initializer : ApplicationContextInitializer { + override fun initialize(configurableApplicationContext: ConfigurableApplicationContext) { + redisContainer.start() + + TestPropertyValues.of( + "spring.redis.host=${redisContainer.host}", + "spring.redis.port=${redisContainer.firstMappedPort}" + ).applyTo(configurableApplicationContext.environment) + } + } +} diff --git a/examples/spring-boot-kotlin-redis/src/test/kotlin/com/example/redis/RedisApplicationTests.kt b/examples/spring-boot-kotlin-redis/src/test/kotlin/com/example/redis/RedisApplicationTests.kt new file mode 100644 index 00000000000..83ab75213ac --- /dev/null +++ b/examples/spring-boot-kotlin-redis/src/test/kotlin/com/example/redis/RedisApplicationTests.kt @@ -0,0 +1,11 @@ +package com.example.redis + +import org.junit.jupiter.api.Test + +class RedisApplicationTests : AbstractIntegrationTest() { + + @Test + fun contextLoads() { + } + +} diff --git a/examples/spring-boot-kotlin-redis/src/test/kotlin/com/example/redis/RedisTest.kt b/examples/spring-boot-kotlin-redis/src/test/kotlin/com/example/redis/RedisTest.kt new file mode 100644 index 00000000000..908bbd41c9c --- /dev/null +++ b/examples/spring-boot-kotlin-redis/src/test/kotlin/com/example/redis/RedisTest.kt @@ -0,0 +1,28 @@ +package com.example.redis + +import org.hamcrest.CoreMatchers.containsString +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + + +class RedisTest : AbstractIntegrationTest() { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Test + fun testRedisFunctionality() { + val greeting = "Hello Testcontainers with Kotlin" + mockMvc.perform(post("/set-foo").content(greeting)) + .andExpect(status().isOk) + + mockMvc.perform(get("/get-foo")) + .andExpect(status().isOk) + .andExpect(content().string(containsString(greeting))) + } +} diff --git a/examples/spring-boot-kotlin-redis/src/test/resources/logback-test.xml b/examples/spring-boot-kotlin-redis/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/spring-boot-kotlin-redis/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/examples/spring-boot/build.gradle b/examples/spring-boot/build.gradle new file mode 100644 index 00000000000..e3a40d27f06 --- /dev/null +++ b/examples/spring-boot/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '2.7.18' +} +apply plugin: 'io.spring.dependency-management' + +repositories { + mavenCentral() +} + +dependencies { + compileOnly "org.projectlombok:lombok" + annotationProcessor "org.projectlombok:lombok" + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-web' + runtimeOnly 'org.postgresql:postgresql' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.testcontainers:testcontainers-postgresql' + testRuntimeOnly "org.junit.platform:junit-platform-launcher:1.8.2" +} + +test { + useJUnitPlatform() +} diff --git a/examples/spring-boot/src/main/java/com/example/DemoApplication.java b/examples/spring-boot/src/main/java/com/example/DemoApplication.java new file mode 100644 index 00000000000..af96419ad6a --- /dev/null +++ b/examples/spring-boot/src/main/java/com/example/DemoApplication.java @@ -0,0 +1,14 @@ +package com.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@SpringBootApplication +@EnableJpaRepositories +public class DemoApplication { + + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } +} diff --git a/examples/spring-boot/src/main/java/com/example/DemoController.java b/examples/spring-boot/src/main/java/com/example/DemoController.java new file mode 100644 index 00000000000..e01f3527a6d --- /dev/null +++ b/examples/spring-boot/src/main/java/com/example/DemoController.java @@ -0,0 +1,36 @@ +package com.example; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class DemoController { + + private final StringRedisTemplate stringRedisTemplate; + + private final DemoService demoService; + + public DemoController(StringRedisTemplate stringRedisTemplate, DemoService demoService) { + this.stringRedisTemplate = stringRedisTemplate; + this.demoService = demoService; + } + + @GetMapping("/foo") + public String get() { + return stringRedisTemplate.opsForValue().get("foo"); + } + + @PutMapping("/foo") + public void set(@RequestBody String value) { + stringRedisTemplate.opsForValue().set("foo", value); + } + + @GetMapping("/{id}") + public DemoEntity getDemoEntity(@PathVariable("id") Long id) { + return demoService.getDemoEntity(id); + } +} diff --git a/examples/spring-boot/src/main/java/com/example/DemoEntity.java b/examples/spring-boot/src/main/java/com/example/DemoEntity.java new file mode 100644 index 00000000000..71fceb2c8e8 --- /dev/null +++ b/examples/spring-boot/src/main/java/com/example/DemoEntity.java @@ -0,0 +1,20 @@ +package com.example; + +import lombok.Data; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +@Data +@Entity +public class DemoEntity { + + @Id + @GeneratedValue + private Long id; + + @Column + private String value; +} diff --git a/examples/spring-boot/src/main/java/com/example/DemoRepository.java b/examples/spring-boot/src/main/java/com/example/DemoRepository.java new file mode 100644 index 00000000000..8ed3216cc18 --- /dev/null +++ b/examples/spring-boot/src/main/java/com/example/DemoRepository.java @@ -0,0 +1,5 @@ +package com.example; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DemoRepository extends JpaRepository {} diff --git a/examples/spring-boot/src/main/java/com/example/DemoService.java b/examples/spring-boot/src/main/java/com/example/DemoService.java new file mode 100644 index 00000000000..78e8c41e907 --- /dev/null +++ b/examples/spring-boot/src/main/java/com/example/DemoService.java @@ -0,0 +1,17 @@ +package com.example; + +import org.springframework.stereotype.Service; + +@Service +public class DemoService { + + private final DemoRepository demoRepository; + + public DemoService(DemoRepository demoRepository) { + this.demoRepository = demoRepository; + } + + public DemoEntity getDemoEntity(Long id) { + return demoRepository.findById(id).orElseThrow(() -> new RuntimeException("Entity not found")); + } +} diff --git a/examples/spring-boot/src/main/resources/application.yml b/examples/spring-boot/src/main/resources/application.yml new file mode 100644 index 00000000000..ed10c24ce75 --- /dev/null +++ b/examples/spring-boot/src/main/resources/application.yml @@ -0,0 +1,11 @@ +spring: + jpa: + hibernate: + ddl-auto: create + show-sql: true + properties: + hibernate: + jdbc: + lob: + non_contextual_creation: true + diff --git a/examples/spring-boot/src/test/java/com/example/AbstractIntegrationTest.java b/examples/spring-boot/src/test/java/com/example/AbstractIntegrationTest.java new file mode 100644 index 00000000000..806e63ed930 --- /dev/null +++ b/examples/spring-boot/src/test/java/com/example/AbstractIntegrationTest.java @@ -0,0 +1,28 @@ +package com.example; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +@SpringBootTest( + classes = DemoApplication.class, + webEnvironment = WebEnvironment.RANDOM_PORT, + properties = { "spring.datasource.url=jdbc:tc:postgresql:11-alpine:///databasename" } +) +@ActiveProfiles("test") +abstract class AbstractIntegrationTest { + + static GenericContainer redis = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + redis.start(); + registry.add("spring.redis.host", redis::getHost); + registry.add("spring.redis.port", redis::getFirstMappedPort); + } +} diff --git a/examples/spring-boot/src/test/java/com/example/DemoControllerTest.java b/examples/spring-boot/src/test/java/com/example/DemoControllerTest.java new file mode 100644 index 00000000000..9e96f1ab1e8 --- /dev/null +++ b/examples/spring-boot/src/test/java/com/example/DemoControllerTest.java @@ -0,0 +1,36 @@ +package com.example; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +class DemoControllerTest extends AbstractIntegrationTest { + + @Autowired + TestRestTemplate restTemplate; + + @Autowired + DemoRepository demoRepository; + + @Test + void simpleTest() { + String fooResource = "/foo"; + + restTemplate.put(fooResource, "bar"); + + assertThat(restTemplate.getForObject(fooResource, String.class)).as("value is set").isEqualTo("bar"); + } + + @Test + void simpleJPATest() { + DemoEntity demoEntity = new DemoEntity(); + demoEntity.setValue("Some value"); + demoRepository.save(demoEntity); + + DemoEntity result = restTemplate.getForObject("/" + demoEntity.getId(), DemoEntity.class); + + assertThat(result.getValue()).as("value is set").isEqualTo("Some value"); + } +} diff --git a/examples/spring-boot/src/test/resources/logback-test.xml b/examples/spring-boot/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/spring-boot/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/examples/zookeeper/build.gradle b/examples/zookeeper/build.gradle new file mode 100644 index 00000000000..4208f4ac266 --- /dev/null +++ b/examples/zookeeper/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation 'org.apache.curator:curator-framework:5.9.0' + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.assertj:assertj-core:3.27.4' + testImplementation 'ch.qos.logback:logback-classic:1.3.15' + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.0' +} + +test { + useJUnitPlatform() +} diff --git a/examples/zookeeper/src/test/java/com/example/ZookeeperContainerTest.java b/examples/zookeeper/src/test/java/com/example/ZookeeperContainerTest.java new file mode 100644 index 00000000000..4518c354e6d --- /dev/null +++ b/examples/zookeeper/src/test/java/com/example/ZookeeperContainerTest.java @@ -0,0 +1,41 @@ +package com.example; + +import org.apache.curator.framework.CuratorFramework; +import org.apache.curator.framework.CuratorFrameworkFactory; +import org.apache.curator.retry.RetryOneTime; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class ZookeeperContainerTest { + + private static final int ZOOKEEPER_PORT = 2181; + + @Test + void test() throws Exception { + String path = "/messages/zk-tc"; + String content = "Running Zookeeper with Testcontainers"; + try ( + GenericContainer zookeeper = new GenericContainer<>("zookeeper:3.8.0").withExposedPorts(ZOOKEEPER_PORT) + ) { + zookeeper.start(); + + String connectionString = zookeeper.getHost() + ":" + zookeeper.getMappedPort(ZOOKEEPER_PORT); + CuratorFramework curatorFramework = CuratorFrameworkFactory + .builder() + .connectString(connectionString) + .retryPolicy(new RetryOneTime(100)) + .build(); + curatorFramework.start(); + curatorFramework.create().creatingParentsIfNeeded().forPath(path, content.getBytes()); + + byte[] bytes = curatorFramework.getData().forPath(path); + curatorFramework.close(); + + assertThat(new String(bytes, StandardCharsets.UTF_8)).isEqualTo(content); + } + } +} diff --git a/examples/zookeeper/src/test/resources/logback-test.xml b/examples/zookeeper/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/examples/zookeeper/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000000..f9e383482f4 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.parallel=false +org.gradle.caching=true +org.gradle.configureondemand=true +org.gradle.jvmargs=-Xmx2g + +testcontainers.version=2.0.5 diff --git a/gradle/ci-support.gradle b/gradle/ci-support.gradle new file mode 100644 index 00000000000..5b85837361e --- /dev/null +++ b/gradle/ci-support.gradle @@ -0,0 +1,34 @@ +import groovy.json.JsonOutput + +// Emit a JSON-formatted list of check tasks to be run in CI +task testMatrix { + project.afterEvaluate { + def checkTasks = subprojects.collect { + it.tasks.findByName("check") + }.findAll { it != null } + + dependsOn(checkTasks) + doLast { + def checkTaskPaths = checkTasks + .collect { it.path } + + println(JsonOutput.toJson(checkTaskPaths)) + } + } +} + +// If we're executing the `testMatrix` task, disable tests and other slow tasks +// so that we can get a result quickly. +gradle.taskGraph.whenReady { + if (it.hasTask(tasks.testMatrix)) { + for (subproject in subprojects) { + subproject.tasks.withType(Test).all { + testExecuter([execute: { spec, processor -> }, stopNow:{}] as org.gradle.api.internal.tasks.testing.TestExecuter) + } + subproject.tasks.findByName("shadowJar")?.enabled = false + subproject.tasks.findByName("javadoc")?.enabled = false + subproject.tasks.findByName("delombok")?.enabled = false + subproject.tasks.findByName("japicmp")?.enabled = false + } + } +} diff --git a/gradle/japicmp.gradle b/gradle/japicmp.gradle new file mode 100644 index 00000000000..a7342c3ba2c --- /dev/null +++ b/gradle/japicmp.gradle @@ -0,0 +1,36 @@ +configurations { + baseline +} + +dependencies { + baseline "org.testcontainers:${project.name}:${project['testcontainers.version']}", { + exclude group: "*", module: "*" + } +} + +tasks.japicmp { + dependsOn(tasks.shadowJar) + + // Disable if baseline dependencies cannot be resolved - such as when developing a new module that doesn't + // have an existing published version. + enabled = ! configurations.baseline.copy().resolvedConfiguration.lenientConfiguration.getFiles().empty + + oldClasspath.from(configurations.baseline) + newClasspath.from(shadowJar.outputs.files) + ignoreMissingClasses = true + + accessModifier = "protected" + failOnModification = true + failOnSourceIncompatibility = true + + onlyBinaryIncompatibleModified = true + htmlOutputFile = file("$buildDir/reports/japi.html") + packageExcludes = [ + "org.testcontainers.shaded.*", + ] +} +// do not run on Windows by default +// TODO investigate zip issue on Windows +if (!org.gradle.internal.os.OperatingSystem.current().isWindows()) { + project.tasks.check.dependsOn(japicmp) +} diff --git a/gradle/publishing.gradle b/gradle/publishing.gradle new file mode 100644 index 00000000000..3e3faaf08ce --- /dev/null +++ b/gradle/publishing.gradle @@ -0,0 +1,123 @@ +apply plugin: 'maven-publish' +apply plugin: 'org.jreleaser' + +task sourceJar(type: Jar) { + archiveClassifier.set( 'sources') + from sourceSets.main.allJava +} + +task javadocJar(type: Jar, dependsOn: javadoc) { + archiveClassifier.set('javadoc') + from javadoc +} + +jar.archiveClassifier.set("original") + +publishing { + publications { + mavenJava(MavenPublication) { publication -> + artifactId = project.name + artifact sourceJar + artifact javadocJar + + artifact project.tasks.jar + artifacts.removeAll { it.classifier == jar.archiveClassifier.get() } + artifact project.tasks.shadowJar + + pom.withXml { + def rootNode = asNode() + rootNode.children().last() + { + resolveStrategy = Closure.DELEGATE_FIRST + + name project.description + description 'Isolated container management for Java code testing' + url 'https://java.testcontainers.org' + issueManagement { + system 'GitHub' + url 'https://github.com/testcontainers/testcontainers-java/issues' + } + licenses { + license { + name 'MIT' + url 'http://opensource.org/licenses/MIT' + } + } + scm { + url 'https://github.com/testcontainers/testcontainers-java/' + connection 'scm:git:git://github.com/testcontainers/testcontainers-java.git' + developerConnection 'scm:git:ssh://git@github.com/testcontainers/testcontainers-java.git' + } + developers { + developer { + id 'rnorth' + name 'Richard North' + email 'rich.north@gmail.com' + } + } + } + + def dependenciesNode = rootNode.appendNode('dependencies') + + def apiDeps= project.configurations.api.resolvedConfiguration.firstLevelModuleDependencies + def providedDeps = project.configurations.provided.resolvedConfiguration.firstLevelModuleDependencies + def newApiDeps = apiDeps - providedDeps + + def addDependencies = { Set resolvedDependencies, scope -> + for (dependency in resolvedDependencies) { + if (dependency.configuration.startsWith("platform-")) { + continue + } + dependenciesNode.appendNode('dependency').with { + if (!dependency.moduleGroup || !dependency.moduleName || !dependency.moduleVersion) { + throw new IllegalStateException("Wrong dependency: $dependency") + } + + appendNode('groupId', dependency.moduleGroup) + appendNode('artifactId', dependency.moduleName) + appendNode('version', dependency.moduleVersion) + appendNode('scope', scope) + + if (dependency instanceof ModuleDependency && !dependency.excludeRules.empty) { + def excludesNode = appendNode('exclusions') + for (rule in dependency.excludeRules) { + excludesNode.appendNode('exclusion').with { + appendNode('groupId', rule.group) + appendNode('artifactId', rule.module) + } + } + } + } + } + } + addDependencies(newApiDeps, 'compile') + addDependencies(providedDeps, 'provided') + } + } + } + repositories { + maven { + url = rootProject.layout.buildDirectory.dir('staging-deploy') + } + } +} + +jreleaser { + signing { + active = 'ALWAYS' + armored = true + } + deploy { + maven { + mavenCentral { + central { + active = 'ALWAYS' + url = 'https://central.sonatype.com/api/v1/publisher' + stagingRepository(rootProject.layout.buildDirectory.dir("staging-deploy").get().toString()) + stage = 'UPLOAD' + applyMavenCentralRules = true + namespace = 'org.testcontainers' + } + } + } + } +} diff --git a/gradle/shading.gradle b/gradle/shading.gradle new file mode 100644 index 00000000000..88f25cc3f22 --- /dev/null +++ b/gradle/shading.gradle @@ -0,0 +1,70 @@ +import java.util.jar.JarFile + +apply plugin: 'com.gradleup.shadow' + +configurations { + shaded + [apiElements, implementation]*.extendsFrom shaded +} +configurations.api.canBeResolved = true + +shadowJar { + configurations = [project.configurations.shaded] + archiveClassifier.set(null) + + mergeServiceFiles() +} + +project.afterEvaluate { + Set apiDependencies = project.configurations.api.resolvedConfiguration.resolvedArtifacts*.moduleVersion*.id*.module + + // Exclude `api` dependencies, to prevent them being included into the final JAR + shadowJar.dependencies { + for (id in apiDependencies) { + exclude(dependency("${id.group}:${id.name}")) + } + } + + // Inherit dependencies' relocators + shadowJar.relocators = configurations.api.dependencies.withType(ProjectDependency).collectMany { + return it.dependencyProject.tasks.findByName("shadowJar")?.relocators ?: [] + } + + // See https://github.com/GradleUp/shadow/blob/5.0.0/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/tasks/ConfigureShadowRelocation.groovy + Set packages = [] + + for (artifact in project.configurations.shaded.resolvedConfiguration.resolvedArtifacts) { + if (apiDependencies.contains(artifact.moduleVersion.id.module)) { + continue + } + + try (def jf = new JarFile(artifact.file)) { + for (entry in jf.entries()) { + def name = entry.name + if (name.endsWith(".class")) { + def index = name.lastIndexOf('/') + if (index != -1) { + packages.add(name.substring(0, index)) + } + } + } + } + } + for (pkg in packages) { + pkg = pkg.replaceAll('/', '.') + + tasks.shadowJar.relocate(pkg, "org.testcontainers.shaded.${pkg}") + } + + // Add shaded JARs first + tasks.test.classpath = findShadedProjects(project) + .plus(project) + .sum { it.tasks.findByName("shadowJar").outputs.files } + .plus(sourceSets.test.runtimeClasspath) +} + +static Set findShadedProjects(Project project) { + Set dependencies = project.configurations.api.dependencies.withType(ProjectDependency)*.dependencyProject + + return dependencies + dependencies.collectMany { it -> findShadedProjects(it) } +} diff --git a/gradle/spotless.gradle b/gradle/spotless.gradle new file mode 100644 index 00000000000..59b834dbad7 --- /dev/null +++ b/gradle/spotless.gradle @@ -0,0 +1,23 @@ +apply plugin: 'com.diffplug.spotless' + +spotless { + java { + toggleOffOn() + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + + prettier(['prettier': '2.5.1', 'prettier-plugin-java': '1.6.1']) + .config([ + 'parser' : 'java', + 'tabWidth' : 4, + 'printWidth': 120 + ]) + + importOrder('', 'java', 'javax', '\\#') + } + groovyGradle { + target '**/*.groovy' + greclipse('4.19') + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000000..1b33c55baab Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000..dbc089ed3d7 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=ed1a8d686605fd7c23bdf62c7fc7add1c5b23b2bbc3721e661934ef4a4911d7c +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000000..23d15a93670 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000000..5eed7ee8452 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000000..ae447212912 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,142 @@ +site_name: Testcontainers for Java +site_url: https://java.testcontainers.org +plugins: + - search + - codeinclude + - markdownextradata +theme: + name: 'material' + custom_dir: 'docs/theme' + palette: + scheme: testcontainers + font: + text: Roboto + code: Roboto Mono + logo: 'logo.svg' + favicon: 'favicon.ico' +extra_css: + - 'css/extra.css' + - 'css/tc-header.css' +repo_name: 'testcontainers-java' +repo_url: 'https://github.com/testcontainers/testcontainers-java' +markdown_extensions: + - admonition + - codehilite: + linenums: False + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - pymdownx.snippets +nav: + - Home: index.md + - Quickstart: + - quickstart/junit_4_quickstart.md + - quickstart/junit_5_quickstart.md + - quickstart/spock_quickstart.md + - Features: + - features/creating_container.md + - features/networking.md + - features/commands.md + - features/files.md + - features/startup_and_waits.md + - features/container_logs.md + - features/creating_images.md + - features/jib.md + - features/configuration.md + - features/image_name_substitution.md + - features/advanced_options.md + - features/reuse.md + - Modules: + - Databases: + - modules/databases/index.md + - modules/databases/jdbc.md + - modules/databases/r2dbc.md + - modules/databases/cassandra.md + - modules/databases/cockroachdb.md + - modules/databases/couchbase.md + - modules/databases/clickhouse.md + - modules/databases/cratedb.md + - modules/databases/databend.md + - modules/databases/db2.md + - modules/databases/influxdb.md + - modules/databases/mariadb.md + - modules/databases/mongodb.md + - modules/databases/mssqlserver.md + - modules/databases/mysql.md + - modules/databases/neo4j.md + - modules/databases/oceanbase.md + - modules/databases/oraclefree.md + - modules/databases/oraclexe.md + - modules/databases/orientdb.md + - modules/databases/postgres.md + - modules/databases/presto.md + - modules/databases/questdb.md + - modules/databases/scylladb.md + - modules/databases/tidb.md + - modules/databases/timeplus.md + - modules/databases/trino.md + - modules/databases/yugabytedb.md + - modules/activemq.md + - modules/azure.md + - modules/chromadb.md + - modules/consul.md + - modules/docker_compose.md + - modules/docker_mcp_gateway.md + - modules/docker_model_runner.md + - modules/elasticsearch.md + - modules/gcloud.md + - modules/grafana.md + - modules/hivemq.md + - modules/k3s.md + - modules/k6.md + - modules/kafka.md + - modules/ldap.md + - modules/localstack.md + - modules/milvus.md + - modules/minio.md + - modules/mockserver.md + - modules/nginx.md + - modules/ollama.md + - modules/openfga.md + - modules/pinecone.md + - modules/pulsar.md + - modules/qdrant.md + - modules/rabbitmq.md + - modules/redpanda.md + - modules/solace.md + - modules/solr.md + - modules/toxiproxy.md + - modules/typesense.md + - modules/vault.md + - modules/weaviate.md + - modules/webdriver_containers.md + - Test framework integration: + - test_framework_integration/junit_4.md + - test_framework_integration/junit_5.md + - test_framework_integration/spock.md + - test_framework_integration/manual_lifecycle_control.md + - test_framework_integration/external.md + - Examples: examples.md + - System Requirements: + - supported_docker_environment/index.md + - error_missing_container_runtime_environment.md + - Continuous Integration: + - supported_docker_environment/continuous_integration/aws_codebuild.md + - supported_docker_environment/continuous_integration/dind_patterns.md + - supported_docker_environment/continuous_integration/circle_ci.md + - supported_docker_environment/continuous_integration/concourse_ci.md + - supported_docker_environment/continuous_integration/drone.md + - supported_docker_environment/continuous_integration/gitlab_ci.md + - supported_docker_environment/continuous_integration/bitbucket_pipelines.md + - supported_docker_environment/continuous_integration/tekton.md + - supported_docker_environment/continuous_integration/travis.md + - supported_docker_environment/windows.md + - supported_docker_environment/logging_config.md + - supported_docker_environment/image_registry_rate_limiting.md + - Getting help: getting_help.md + - Contributing: + - contributing.md + - contributing_docs.md +edit_uri: edit/main/docs/ +extra: + latest_version: 2.0.5 diff --git a/modules/activemq/build.gradle b/modules/activemq/build.gradle new file mode 100644 index 00000000000..c77b386698f --- /dev/null +++ b/modules/activemq/build.gradle @@ -0,0 +1,8 @@ +description = "Testcontainers :: ActiveMQ" + +dependencies { + api project(':testcontainers') + + testImplementation "org.apache.activemq:activemq-client:6.2.4" + testImplementation "org.apache.activemq:artemis-jakarta-client:2.55.0" +} diff --git a/modules/activemq/src/main/java/org/testcontainers/activemq/ActiveMQContainer.java b/modules/activemq/src/main/java/org/testcontainers/activemq/ActiveMQContainer.java new file mode 100644 index 00000000000..9313efc55d6 --- /dev/null +++ b/modules/activemq/src/main/java/org/testcontainers/activemq/ActiveMQContainer.java @@ -0,0 +1,89 @@ +package org.testcontainers.activemq; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; + +/** + * Testcontainers implementation for Apache ActiveMQ. + *

+ * Exposed ports: + *

    + *
  • Console: 8161
  • + *
  • TCP: 61616
  • + *
  • AMQP: 5672
  • + *
  • STOMP: 61613
  • + *
  • MQTT: 1883
  • + *
  • WS: 61614
  • + *
+ */ +public class ActiveMQContainer extends GenericContainer { + + private static final DockerImageName APACHE_ACTIVEMQ_CLASSIC_IMAGE = DockerImageName.parse( + "apache/activemq-classic" + ); + + private static final DockerImageName DEFAULT_IMAGE = DockerImageName.parse("apache/activemq"); + + private static final int WEB_CONSOLE_PORT = 8161; + + private static final int TCP_PORT = 61616; + + private static final int AMQP_PORT = 5672; + + private static final int STOMP_PORT = 61613; + + private static final int MQTT_PORT = 1883; + + private static final int WS_PORT = 61614; + + private String username; + + private String password; + + public ActiveMQContainer(String image) { + this(DockerImageName.parse(image)); + } + + public ActiveMQContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE, APACHE_ACTIVEMQ_CLASSIC_IMAGE); + + withExposedPorts(WEB_CONSOLE_PORT, TCP_PORT, AMQP_PORT, STOMP_PORT, MQTT_PORT, WS_PORT); + waitingFor(Wait.forLogMessage(".*Apache ActiveMQ.*started.*", 1).withStartupTimeout(Duration.ofMinutes(1))); + } + + @Override + protected void configure() { + if (this.username != null) { + addEnv("ACTIVEMQ_CONNECTION_USER", this.username); + } + if (this.password != null) { + addEnv("ACTIVEMQ_CONNECTION_PASSWORD", this.password); + } + } + + public ActiveMQContainer withUser(String username) { + this.username = username; + return this; + } + + public ActiveMQContainer withPassword(String password) { + this.password = password; + return this; + } + + public String getBrokerUrl() { + return String.format("tcp://%s:%s", getHost(), getMappedPort(TCP_PORT)); + } + + public String getUser() { + return this.username; + } + + public String getPassword() { + return this.password; + } +} diff --git a/modules/activemq/src/main/java/org/testcontainers/activemq/ArtemisContainer.java b/modules/activemq/src/main/java/org/testcontainers/activemq/ArtemisContainer.java new file mode 100644 index 00000000000..82072993b48 --- /dev/null +++ b/modules/activemq/src/main/java/org/testcontainers/activemq/ArtemisContainer.java @@ -0,0 +1,89 @@ +package org.testcontainers.activemq; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; + +/** + * Testcontainers implementation for Apache ActiveMQ Artemis. + *

+ * Supported images: {@code apache/artemis}, {@code apache/activemq-artemis} + *

+ * Exposed ports: + *

    + *
  • Console: 8161
  • + *
  • TCP: 61616
  • + *
  • HORNETQ: 5445
  • + *
  • AMQP: 5672
  • + *
  • STOMP: 61613
  • + *
  • MQTT: 1883
  • + *
  • WS: 61614
  • + *
+ */ +public class ArtemisContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE = DockerImageName.parse("apache/activemq-artemis"); + + private static final DockerImageName APACHE_ARTEMIS_IMAGE = DockerImageName.parse("apache/artemis"); + + private static final int WEB_CONSOLE_PORT = 8161; + + // CORE,MQTT,AMQP,HORNETQ,STOMP,OPENWIRE + private static final int TCP_PORT = 61616; + + private static final int HORNETQ_STOMP_PORT = 5445; + + private static final int AMQP_PORT = 5672; + + private static final int STOMP_PORT = 61613; + + private static final int MQTT_PORT = 1883; + + private static final int WS_PORT = 61614; + + private String username = "artemis"; + + private String password = "artemis"; + + public ArtemisContainer(String image) { + this(DockerImageName.parse(image)); + } + + public ArtemisContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE, APACHE_ARTEMIS_IMAGE); + + withExposedPorts(WEB_CONSOLE_PORT, TCP_PORT, HORNETQ_STOMP_PORT, AMQP_PORT, STOMP_PORT, MQTT_PORT, WS_PORT); + waitingFor(Wait.forLogMessage(".*HTTP Server started.*", 1).withStartupTimeout(Duration.ofMinutes(1))); + } + + @Override + protected void configure() { + withEnv("ARTEMIS_USER", this.username); + withEnv("ARTEMIS_PASSWORD", this.password); + } + + public ArtemisContainer withUser(String username) { + this.username = username; + return this; + } + + public ArtemisContainer withPassword(String password) { + this.password = password; + return this; + } + + public String getBrokerUrl() { + return String.format("tcp://%s:%s", getHost(), getMappedPort(TCP_PORT)); + } + + public String getUser() { + return getEnvMap().get("ARTEMIS_USER"); + } + + public String getPassword() { + return getEnvMap().get("ARTEMIS_PASSWORD"); + } +} diff --git a/modules/activemq/src/test/java/org/testcontainers/activemq/ActiveMQContainerTest.java b/modules/activemq/src/test/java/org/testcontainers/activemq/ActiveMQContainerTest.java new file mode 100644 index 00000000000..cb66ebba864 --- /dev/null +++ b/modules/activemq/src/test/java/org/testcontainers/activemq/ActiveMQContainerTest.java @@ -0,0 +1,84 @@ +package org.testcontainers.activemq; + +import jakarta.jms.Connection; +import jakarta.jms.ConnectionFactory; +import jakarta.jms.Destination; +import jakarta.jms.MessageConsumer; +import jakarta.jms.MessageProducer; +import jakarta.jms.Session; +import jakarta.jms.TextMessage; +import lombok.SneakyThrows; +import org.apache.activemq.ActiveMQConnectionFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class ActiveMQContainerTest { + + @Test + void test() { + try ( // container { + ActiveMQContainer activemq = new ActiveMQContainer("apache/activemq:5.18.7") + // } + ) { + activemq.start(); + + assertThat(activemq.getUser()).isNull(); + assertThat(activemq.getPassword()).isNull(); + assertFunctionality(activemq, false); + } + } + + @ParameterizedTest + @ValueSource(strings = { "apache/activemq-classic:5.18.7", "apache/activemq:5.18.7" }) + void compatibility(String image) { + try (ActiveMQContainer activemq = new ActiveMQContainer(image)) { + activemq.start(); + assertFunctionality(activemq, false); + } + } + + @Test + void customCredentials() { + try ( + // settingCredentials { + ActiveMQContainer activemq = new ActiveMQContainer("apache/activemq:5.18.7") + .withUser("testcontainers") + .withPassword("testcontainers") + // } + ) { + activemq.start(); + + assertThat(activemq.getUser()).isEqualTo("testcontainers"); + assertThat(activemq.getPassword()).isEqualTo("testcontainers"); + assertFunctionality(activemq, true); + } + } + + @SneakyThrows + private void assertFunctionality(ActiveMQContainer activemq, boolean useCredentials) { + ConnectionFactory connectionFactory = new ActiveMQConnectionFactory(activemq.getBrokerUrl()); + Connection connection; + if (useCredentials) { + connection = connectionFactory.createConnection(activemq.getUser(), activemq.getPassword()); + } else { + connection = connectionFactory.createConnection(); + } + connection.start(); + + Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE); + + Destination destination = session.createQueue("test-queue"); + MessageProducer producer = session.createProducer(destination); + + String contentMessage = "Testcontainers"; + TextMessage message = session.createTextMessage(contentMessage); + producer.send(message); + + MessageConsumer consumer = session.createConsumer(destination); + TextMessage messageReceived = (TextMessage) consumer.receive(); + assertThat(messageReceived.getText()).isEqualTo(contentMessage); + } +} diff --git a/modules/activemq/src/test/java/org/testcontainers/activemq/ArtemisContainerTest.java b/modules/activemq/src/test/java/org/testcontainers/activemq/ArtemisContainerTest.java new file mode 100644 index 00000000000..74cdfa76da7 --- /dev/null +++ b/modules/activemq/src/test/java/org/testcontainers/activemq/ArtemisContainerTest.java @@ -0,0 +1,97 @@ +package org.testcontainers.activemq; + +import jakarta.jms.Connection; +import jakarta.jms.Destination; +import jakarta.jms.MessageConsumer; +import jakarta.jms.MessageProducer; +import jakarta.jms.Session; +import jakarta.jms.TextMessage; +import lombok.SneakyThrows; +import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class ArtemisContainerTest { + + @Test + void defaultCredentials() { + try ( + // container { + ArtemisContainer artemis = new ArtemisContainer("apache/activemq-artemis:2.32.0-alpine") + // } + ) { + artemis.start(); + + assertThat(artemis.getUser()).isEqualTo("artemis"); + assertThat(artemis.getPassword()).isEqualTo("artemis"); + assertFunctionality(artemis, false); + } + } + + @Test + void customCredentials() { + try ( + // settingCredentials { + ArtemisContainer artemis = new ArtemisContainer("apache/activemq-artemis:2.32.0-alpine") + .withUser("testcontainers") + .withPassword("testcontainers") + // } + ) { + artemis.start(); + + assertThat(artemis.getUser()).isEqualTo("testcontainers"); + assertThat(artemis.getPassword()).isEqualTo("testcontainers"); + assertFunctionality(artemis, false); + } + } + + @Test + void allowAnonymousLogin() { + try ( + // enableAnonymousLogin { + ArtemisContainer artemis = new ArtemisContainer("apache/activemq-artemis:2.32.0-alpine") + .withEnv("ANONYMOUS_LOGIN", "true") + // } + ) { + artemis.start(); + + assertFunctionality(artemis, true); + } + } + + @ParameterizedTest + @ValueSource(strings = { "apache/activemq-artemis:2.32.0-alpine", "apache/artemis:2.53.0-alpine" }) + void compatibility(String image) { + try (ArtemisContainer artemis = new ArtemisContainer(image)) { + artemis.start(); + assertFunctionality(artemis, false); + } + } + + @SneakyThrows + private void assertFunctionality(ArtemisContainer artemis, boolean anonymousLogin) { + ActiveMQConnectionFactory connectionFactory = new ActiveMQConnectionFactory(artemis.getBrokerUrl()); + if (!anonymousLogin) { + connectionFactory.setUser(artemis.getUser()); + connectionFactory.setPassword(artemis.getPassword()); + } + Connection connection = connectionFactory.createConnection(); + connection.start(); + + Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE); + + Destination destination = session.createQueue("test-queue"); + MessageProducer producer = session.createProducer(destination); + + String contentMessage = "Testcontainers"; + TextMessage message = session.createTextMessage(contentMessage); + producer.send(message); + + MessageConsumer consumer = session.createConsumer(destination); + TextMessage messageReceived = (TextMessage) consumer.receive(); + assertThat(messageReceived.getText()).isEqualTo(contentMessage); + } +} diff --git a/modules/activemq/src/test/resources/logback-test.xml b/modules/activemq/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/activemq/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/azure/build.gradle b/modules/azure/build.gradle new file mode 100644 index 00000000000..167c0c26f1a --- /dev/null +++ b/modules/azure/build.gradle @@ -0,0 +1,21 @@ +description = "Testcontainers :: Azure" + +dependencies { + api project(':testcontainers') + api project(':testcontainers-mssqlserver') + // TODO use JDK's HTTP client and/or Apache HttpClient5 + shaded 'com.squareup.okhttp3:okhttp:5.4.0' + + testImplementation platform("com.azure:azure-sdk-bom:1.2.32") + testImplementation 'com.azure:azure-cosmos' + testImplementation 'com.azure:azure-storage-blob' + testImplementation 'com.azure:azure-storage-queue' + testImplementation 'com.azure:azure-data-tables' + testImplementation 'com.azure:azure-messaging-eventhubs' + testImplementation 'com.azure:azure-messaging-servicebus' + testImplementation 'com.microsoft.sqlserver:mssql-jdbc:13.4.0.jre11' +} + +tasks.japicmp { + methodExcludes = ["org.testcontainers.azure.ServiceBusEmulatorContainer#withMsSqlServerContainer(org.testcontainers.containers.MSSQLServerContainer)"] +} diff --git a/modules/azure/src/main/java/org/testcontainers/azure/AzuriteContainer.java b/modules/azure/src/main/java/org/testcontainers/azure/AzuriteContainer.java new file mode 100644 index 00000000000..11e34237740 --- /dev/null +++ b/modules/azure/src/main/java/org/testcontainers/azure/AzuriteContainer.java @@ -0,0 +1,190 @@ +package org.testcontainers.azure; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Testcontainers implementation for Azurite Emulator. + *

+ * Supported image: {@code mcr.microsoft.com/azure-storage/azurite} + *

+ * Exposed ports: + *

    + *
  • Blob: 10000
  • + *
  • Queue: 10001
  • + *
  • Table: 10002
  • + *
+ */ +public class AzuriteContainer extends GenericContainer { + + private static final String ALLOW_ALL_CONNECTIONS = "0.0.0.0"; + + private static final int DEFAULT_BLOB_PORT = 10000; + + private static final int DEFAULT_QUEUE_PORT = 10001; + + private static final int DEFAULT_TABLE_PORT = 10002; + + private static final String CONNECTION_STRING_FORMAT = + "DefaultEndpointsProtocol=%s;AccountName=%s;AccountKey=%s;BlobEndpoint=%s://%s:%d/%s;QueueEndpoint=%s://%s:%d/%s;TableEndpoint=%s://%s:%d/%s;"; + + /** + * The account name of the default credentials. + */ + private static final String WELL_KNOWN_ACCOUNT_NAME = "devstoreaccount1"; + + /** + * The account key of the default credentials. + */ + private static final String WELL_KNOWN_ACCOUNT_KEY = + "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "mcr.microsoft.com/azure-storage/azurite" + ); + + private MountableFile cert = null; + + private String certExtension = null; + + private MountableFile key = null; + + private String pwd = null; + + private final List commandOptions = new ArrayList<>(); + + /** + * @param dockerImageName specified docker image name to run + */ + public AzuriteContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + /** + * @param dockerImageName specified docker image name to run + */ + public AzuriteContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withExposedPorts(DEFAULT_BLOB_PORT, DEFAULT_QUEUE_PORT, DEFAULT_TABLE_PORT); + } + + /** + * Configure SSL with a custom certificate and password. + * + * @param pfxCert The PFX certificate file + * @param password The password securing the certificate + * @return this + */ + public AzuriteContainer withSsl(final MountableFile pfxCert, final String password) { + this.cert = pfxCert; + this.pwd = password; + this.certExtension = ".pfx"; + return this; + } + + /** + * Configure SSL with a custom certificate and private key. + * + * @param pemCert The PEM certificate file + * @param pemKey The PEM key file + * @return this + */ + public AzuriteContainer withSsl(final MountableFile pemCert, final MountableFile pemKey) { + this.cert = pemCert; + this.key = pemKey; + this.certExtension = ".pem"; + return this; + } + + /** + * Append extra Azurite command line flags after the default host and SSL arguments. + *

+ * {@link #withCommand(String...)} cannot be used for this: {@link #configure()} always + * rebuilds the process command and overwrites any previously set command. + * + * @param options additional Azurite flags, for example {@code --skipApiVersionCheck} + * @return this + */ + public AzuriteContainer withCommandOptions(String... options) { + this.commandOptions.addAll(Arrays.asList(options)); + return this; + } + + @Override + protected void configure() { + withCommand(getCommandLine()); + if (this.cert != null) { + logger().info("Using path for cert file: '{}'", this.cert); + withCopyFileToContainer(this.cert, "/cert" + this.certExtension); + if (this.key != null) { + logger().info("Using path for key file: '{}'", this.key); + withCopyFileToContainer(this.key, "/key.pem"); + } + } + } + + /** + * Returns the connection string for the default credentials. + * + * @return connection string + */ + public String getConnectionString() { + return getConnectionString(WELL_KNOWN_ACCOUNT_NAME, WELL_KNOWN_ACCOUNT_KEY); + } + + /** + * Returns the connection string for the account name and key specified. + * + * @param accountName The name of the account + * @param accountKey The account key + * @return connection string + */ + public String getConnectionString(final String accountName, final String accountKey) { + final String protocol = cert != null ? "https" : "http"; + return String.format( + CONNECTION_STRING_FORMAT, + protocol, + accountName, + accountKey, + protocol, + getHost(), + getMappedPort(DEFAULT_BLOB_PORT), + accountName, + protocol, + getHost(), + getMappedPort(DEFAULT_QUEUE_PORT), + accountName, + protocol, + getHost(), + getMappedPort(DEFAULT_TABLE_PORT), + accountName + ); + } + + String getCommandLine() { + final StringBuilder args = new StringBuilder("azurite"); + args.append(" --blobHost ").append(ALLOW_ALL_CONNECTIONS); + args.append(" --queueHost ").append(ALLOW_ALL_CONNECTIONS); + args.append(" --tableHost ").append(ALLOW_ALL_CONNECTIONS); + if (this.cert != null) { + args.append(" --cert ").append("/cert").append(this.certExtension); + if (this.pwd != null) { + args.append(" --pwd ").append(this.pwd); + } else { + args.append(" --key ").append("/key.pem"); + } + } + for (String option : this.commandOptions) { + args.append(" ").append(option); + } + final String cmd = args.toString(); + logger().debug("Using command line: '{}'", cmd); + return cmd; + } +} diff --git a/modules/azure/src/main/java/org/testcontainers/azure/EventHubsEmulatorContainer.java b/modules/azure/src/main/java/org/testcontainers/azure/EventHubsEmulatorContainer.java new file mode 100644 index 00000000000..257f71a1424 --- /dev/null +++ b/modules/azure/src/main/java/org/testcontainers/azure/EventHubsEmulatorContainer.java @@ -0,0 +1,109 @@ +package org.testcontainers.azure; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.LicenseAcceptance; + +/** + * Testcontainers implementation for Azure Eventhubs Emulator. + *

+ * Supported image: {@code "mcr.microsoft.com/azure-messaging/eventhubs-emulator"} + *

+ * Exposed ports: + *

    + *
  • AMQP: 5672
  • + *
+ */ +public class EventHubsEmulatorContainer extends GenericContainer { + + private static final int DEFAULT_AMQP_PORT = 5672; + + private static final String CONNECTION_STRING_FORMAT = + "Endpoint=sb://%s:%d;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "mcr.microsoft.com/azure-messaging/eventhubs-emulator" + ); + + private AzuriteContainer azuriteContainer; + + /** + * @param dockerImageName specified docker image name to run + */ + public EventHubsEmulatorContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + /** + * @param dockerImageName specified docker image name to run + */ + public EventHubsEmulatorContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + waitingFor(Wait.forLogMessage(".*Emulator Service is Successfully Up!.*", 1)); + withExposedPorts(DEFAULT_AMQP_PORT); + } + + /** + * * Sets the Azurite dependency needed by the Event Hubs Container, + * + * @param azuriteContainer The Azurite container used by Event HUbs as a dependency + * @return this + */ + public EventHubsEmulatorContainer withAzuriteContainer(final AzuriteContainer azuriteContainer) { + this.azuriteContainer = azuriteContainer; + dependsOn(this.azuriteContainer); + return this; + } + + /** + * Provide the broker configuration to the container. + * + * @param config The file containing the broker configuration + * @return this + */ + public EventHubsEmulatorContainer withConfig(final Transferable config) { + withCopyToContainer(config, "/Eventhubs_Emulator/ConfigFiles/Config.json"); + return this; + } + + /** + * Accepts the EULA of the container. + * + * @return this + */ + public EventHubsEmulatorContainer acceptLicense() { + withEnv("ACCEPT_EULA", "Y"); + return this; + } + + @Override + protected void configure() { + if (azuriteContainer == null) { + throw new IllegalStateException( + "The image " + + getDockerImageName() + + " requires an Azurite container. Please provide one with the withAzuriteContainer method!" + ); + } + final String azuriteHost = azuriteContainer.getNetworkAliases().get(0); + withEnv("BLOB_SERVER", azuriteHost); + withEnv("METADATA_SERVER", azuriteHost); + // If license was not accepted programmatically, check if it was accepted via resource file + if (!getEnvMap().containsKey("ACCEPT_EULA")) { + LicenseAcceptance.assertLicenseAccepted(this.getDockerImageName()); + acceptLicense(); + } + } + + /** + * Returns the connection string. + * + * @return connection string + */ + public String getConnectionString() { + return String.format(CONNECTION_STRING_FORMAT, getHost(), getMappedPort(DEFAULT_AMQP_PORT)); + } +} diff --git a/modules/azure/src/main/java/org/testcontainers/azure/ServiceBusEmulatorContainer.java b/modules/azure/src/main/java/org/testcontainers/azure/ServiceBusEmulatorContainer.java new file mode 100644 index 00000000000..4557b6acb6a --- /dev/null +++ b/modules/azure/src/main/java/org/testcontainers/azure/ServiceBusEmulatorContainer.java @@ -0,0 +1,107 @@ +package org.testcontainers.azure; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.mssqlserver.MSSQLServerContainer; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.LicenseAcceptance; + +/** + * Testcontainers implementation for Azure Service Bus Emulator. + *

+ * Supported image: {@code mcr.microsoft.com/azure-messaging/servicebus-emulator} + *

+ * Exposed port: 5672 + */ +public class ServiceBusEmulatorContainer extends GenericContainer { + + private static final String CONNECTION_STRING_FORMAT = + "Endpoint=sb://%s:%d;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"; + + private static final int DEFAULT_PORT = 5672; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "mcr.microsoft.com/azure-messaging/servicebus-emulator" + ); + + private MSSQLServerContainer msSqlServerContainer; + + /** + * @param dockerImageName The specified docker image name to run + */ + public ServiceBusEmulatorContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + /** + * @param dockerImageName The specified docker image name to run + */ + public ServiceBusEmulatorContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withExposedPorts(DEFAULT_PORT); + withEnv("SQL_WAIT_INTERVAL", "0"); + waitingFor(Wait.forLogMessage(".*Emulator Service is Successfully Up!.*", 1)); + } + + /** + * Sets the MS SQL Server dependency needed by the Service Bus Container, + * + * @param msSqlServerContainer The MS SQL Server container used by Service Bus as a dependency + * @return this + */ + public ServiceBusEmulatorContainer withMsSqlServerContainer(final MSSQLServerContainer msSqlServerContainer) { + dependsOn(msSqlServerContainer); + this.msSqlServerContainer = msSqlServerContainer; + return this; + } + + /** + * Provide the Service Bus configuration JSON. + * + * @param config The configuration + * @return this + */ + public ServiceBusEmulatorContainer withConfig(final Transferable config) { + withCopyToContainer(config, "/ServiceBus_Emulator/ConfigFiles/Config.json"); + return this; + } + + /** + * Accepts the EULA of the container. + * + * @return this + */ + public ServiceBusEmulatorContainer acceptLicense() { + withEnv("ACCEPT_EULA", "Y"); + return this; + } + + @Override + protected void configure() { + if (msSqlServerContainer == null) { + throw new IllegalStateException( + "The image " + + getDockerImageName() + + " requires a Microsoft SQL Server container. Please provide one with the withMsSqlServerContainer method!" + ); + } + withEnv("SQL_SERVER", msSqlServerContainer.getNetworkAliases().get(0)); + withEnv("MSSQL_SA_PASSWORD", msSqlServerContainer.getPassword()); + // If license was not accepted programmatically, check if it was accepted via resource file + if (!getEnvMap().containsKey("ACCEPT_EULA")) { + LicenseAcceptance.assertLicenseAccepted(this.getDockerImageName()); + acceptLicense(); + } + } + + /** + * Returns the connection string. + * + * @return connection string + */ + public String getConnectionString() { + return String.format(CONNECTION_STRING_FORMAT, getHost(), getMappedPort(DEFAULT_PORT)); + } +} diff --git a/modules/azure/src/main/java/org/testcontainers/containers/CosmosDBEmulatorContainer.java b/modules/azure/src/main/java/org/testcontainers/containers/CosmosDBEmulatorContainer.java new file mode 100644 index 00000000000..a1cfdeaf124 --- /dev/null +++ b/modules/azure/src/main/java/org/testcontainers/containers/CosmosDBEmulatorContainer.java @@ -0,0 +1,57 @@ +package org.testcontainers.containers; + +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.security.KeyStore; + +/** + * Testcontainers implementation for CosmosDB Emulator. + *

+ * Supported image: {@code mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator} + *

+ * Exposed ports: 8081 + */ +public class CosmosDBEmulatorContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator" + ); + + private static final int PORT = 8081; + + /** + * @param dockerImageName specified docker image name to run + */ + public CosmosDBEmulatorContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withExposedPorts(PORT); + waitingFor(Wait.forLogMessage(".*Started\\r\\n$", 1)); + } + + /** + * @return new KeyStore built with PKCS12 + */ + public KeyStore buildNewKeyStore() { + return KeyStoreBuilder.buildByDownloadingCertificate(getEmulatorEndpoint(), getEmulatorKey()); + } + + /** + * Emulator key is a known constant and specified in Azure Cosmos DB Documents. + * This key is also used as password for emulator certificate file. + * + * @return predefined emulator key + * @see Azure Cosmos DB Documents + */ + public String getEmulatorKey() { + return "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; + } + + /** + * @return secure https emulator endpoint to send requests + */ + public String getEmulatorEndpoint() { + return "https://" + getHost() + ":" + getMappedPort(PORT); + } +} diff --git a/modules/azure/src/main/java/org/testcontainers/containers/KeyStoreBuilder.java b/modules/azure/src/main/java/org/testcontainers/containers/KeyStoreBuilder.java new file mode 100644 index 00000000000..f7b6af370ac --- /dev/null +++ b/modules/azure/src/main/java/org/testcontainers/containers/KeyStoreBuilder.java @@ -0,0 +1,99 @@ +package org.testcontainers.containers; + +import okhttp3.Cache; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +import java.io.InputStream; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Objects; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +final class KeyStoreBuilder { + + static KeyStore buildByDownloadingCertificate(String endpoint, String keyStorePassword) { + OkHttpClient client = null; + Response response = null; + try { + TrustManager[] trustAllManagers = buildTrustAllManagers(); + client = buildTrustAllClient(trustAllManagers); + Request request = buildRequest(endpoint); + response = client.newCall(request).execute(); + return buildKeyStore(response.body().byteStream(), keyStorePassword); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } finally { + closeResponseSilently(response); + closeClientSilently(client); + } + } + + private static TrustManager[] buildTrustAllManagers() { + return new TrustManager[] { + new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) {} + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) {} + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[] {}; + } + }, + }; + } + + private static OkHttpClient buildTrustAllClient(TrustManager[] trustManagers) throws Exception { + SSLContext sslContext = SSLContext.getInstance("SSL"); + sslContext.init(null, trustManagers, new SecureRandom()); + SSLSocketFactory socketFactory = sslContext.getSocketFactory(); + return new OkHttpClient.Builder() + .sslSocketFactory(socketFactory, (X509TrustManager) trustManagers[0]) + .hostnameVerifier((s, sslSession) -> true) + .build(); + } + + private static Request buildRequest(String endpoint) { + return new Request.Builder().get().url(endpoint + "/_explorer/emulator.pem").build(); + } + + private static KeyStore buildKeyStore(InputStream certificateStream, String keyStorePassword) throws Exception { + Certificate certificate = CertificateFactory.getInstance("X.509").generateCertificate(certificateStream); + KeyStore keystore = KeyStore.getInstance("PKCS12"); + keystore.load(null, keyStorePassword.toCharArray()); + keystore.setCertificateEntry("azure-cosmos-emulator", certificate); + return keystore; + } + + private static void closeResponseSilently(Response response) { + try { + if (Objects.nonNull(response)) { + response.close(); + } + } catch (Exception ignored) {} + } + + private static void closeClientSilently(OkHttpClient client) { + try { + if (Objects.nonNull(client)) { + client.dispatcher().executorService().shutdown(); + client.connectionPool().evictAll(); + Cache cache = client.cache(); + if (Objects.nonNull(cache)) { + cache.close(); + } + } + } catch (Exception ignored) {} + } +} diff --git a/modules/azure/src/test/java/org/testcontainers/azure/AzuriteContainerCommandTest.java b/modules/azure/src/test/java/org/testcontainers/azure/AzuriteContainerCommandTest.java new file mode 100644 index 00000000000..c12e77ed69a --- /dev/null +++ b/modules/azure/src/test/java/org/testcontainers/azure/AzuriteContainerCommandTest.java @@ -0,0 +1,62 @@ +package org.testcontainers.azure; + +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.MountableFile; + +import static org.assertj.core.api.Assertions.assertThat; + +class AzuriteContainerCommandTest { + + private static final String IMAGE = "mcr.microsoft.com/azure-storage/azurite:3.33.0"; + + @Test + void commandLineOmitsExtraOptionsByDefault() { + AzuriteContainer emulator = new AzuriteContainer(IMAGE); + + assertThat(emulator.getCommandLine()).doesNotContain("--skipApiVersionCheck"); + } + + @Test + void commandLineIncludesExtraOptions() { + // commandOptions { + AzuriteContainer emulator = new AzuriteContainer("mcr.microsoft.com/azure-storage/azurite:3.33.0") + .withCommandOptions("--skipApiVersionCheck"); + // } + + assertThat(emulator.getCommandLine()) + .startsWith("azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0") + .endsWith("--skipApiVersionCheck"); + } + + @Test + void commandLineAppendsMultipleOptions() { + AzuriteContainer emulator = new AzuriteContainer(IMAGE) + .withCommandOptions("--skipApiVersionCheck", "--disableProductStyleUrl"); + + assertThat(emulator.getCommandLine()).endsWith("--skipApiVersionCheck --disableProductStyleUrl"); + } + + @Test + void commandLineKeepsExtraOptionsTogetherWithSsl() { + AzuriteContainer emulator = new AzuriteContainer(IMAGE) + .withSsl(MountableFile.forClasspathResource("/keystore.pfx"), "changeit") + .withCommandOptions("--skipApiVersionCheck"); + + assertThat(emulator.getCommandLine()) + .contains("--cert /cert.pfx") + .endsWith("--pwd changeit --skipApiVersionCheck"); + } + + @Test + void configureAppliesCommandOptionsEvenIfWithCommandWasUsed() { + AzuriteContainer emulator = new AzuriteContainer(IMAGE) + .withCommand("azurite --ignored") + .withCommandOptions("--skipApiVersionCheck"); + + emulator.configure(); + + assertThat(String.join(" ", emulator.getCommandParts())) + .isEqualTo("azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --skipApiVersionCheck"); + assertThat(emulator.getCommandLine()).contains("--blobHost 0.0.0.0").contains("--skipApiVersionCheck"); + } +} diff --git a/modules/azure/src/test/java/org/testcontainers/azure/AzuriteContainerTest.java b/modules/azure/src/test/java/org/testcontainers/azure/AzuriteContainerTest.java new file mode 100644 index 00000000000..5acf2fe5ad5 --- /dev/null +++ b/modules/azure/src/test/java/org/testcontainers/azure/AzuriteContainerTest.java @@ -0,0 +1,266 @@ +package org.testcontainers.azure; + +import com.azure.core.util.BinaryData; +import com.azure.data.tables.TableClient; +import com.azure.data.tables.TableServiceClient; +import com.azure.data.tables.TableServiceClientBuilder; +import com.azure.storage.blob.BlobClient; +import com.azure.storage.blob.BlobContainerClient; +import com.azure.storage.blob.BlobServiceClient; +import com.azure.storage.blob.BlobServiceClientBuilder; +import com.azure.storage.queue.QueueClient; +import com.azure.storage.queue.QueueServiceClient; +import com.azure.storage.queue.QueueServiceClientBuilder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.MountableFile; + +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +class AzuriteContainerTest { + + private static final String PASSWORD = "changeit"; + + private static Properties originalSystemProperties; + + @BeforeAll + public static void captureOriginalSystemProperties() { + originalSystemProperties = (Properties) System.getProperties().clone(); + System.setProperty( + "javax.net.ssl.trustStore", + MountableFile.forClasspathResource("/keystore.pfx").getFilesystemPath() + ); + System.setProperty("javax.net.ssl.trustStorePassword", PASSWORD); + System.setProperty("javax.net.ssl.trustStoreType", "PKCS12"); + } + + @AfterAll + public static void restoreOriginalSystemProperties() { + System.setProperties(originalSystemProperties); + } + + @Test + void testWithBlobServiceClient() { + try ( + // emulatorContainer { + AzuriteContainer emulator = new AzuriteContainer("mcr.microsoft.com/azure-storage/azurite:3.33.0") + // } + ) { + emulator.start(); + assertThat(emulator.getConnectionString()).contains("BlobEndpoint=http://"); + testBlob(emulator); + } + } + + @Test + void testWithQueueServiceClient() { + try (AzuriteContainer emulator = new AzuriteContainer("mcr.microsoft.com/azure-storage/azurite:3.33.0")) { + emulator.start(); + assertThat(emulator.getConnectionString()).contains("QueueEndpoint=http://"); + testQueue(emulator); + } + } + + @Test + void testWithTableServiceClient() { + try (AzuriteContainer emulator = new AzuriteContainer("mcr.microsoft.com/azure-storage/azurite:3.33.0")) { + emulator.start(); + assertThat(emulator.getConnectionString()).contains("TableEndpoint=http://"); + testTable(emulator); + } + } + + @Test + void testWithBlobServiceClientWithSslUsingPfx() { + try ( + AzuriteContainer emulator = new AzuriteContainer("mcr.microsoft.com/azure-storage/azurite:3.33.0") + .withSsl(MountableFile.forClasspathResource("/keystore.pfx"), PASSWORD) + ) { + emulator.start(); + assertThat(emulator.getConnectionString()).contains("BlobEndpoint=https://"); + testBlob(emulator); + } + } + + @Test + void testWithQueueServiceClientWithSslUsingPfx() { + try ( + AzuriteContainer emulator = new AzuriteContainer("mcr.microsoft.com/azure-storage/azurite:3.33.0") + .withSsl(MountableFile.forClasspathResource("/keystore.pfx"), PASSWORD) + ) { + emulator.start(); + assertThat(emulator.getConnectionString()).contains("QueueEndpoint=https://"); + testQueue(emulator); + } + } + + @Test + void testWithTableServiceClientWithSslUsingPfx() { + try ( + AzuriteContainer emulator = new AzuriteContainer("mcr.microsoft.com/azure-storage/azurite:3.33.0") + .withSsl(MountableFile.forClasspathResource("/keystore.pfx"), PASSWORD) + ) { + emulator.start(); + assertThat(emulator.getConnectionString()).contains("TableEndpoint=https://"); + testTable(emulator); + } + } + + @Test + void testWithBlobServiceClientWithSslUsingPem() { + try ( + AzuriteContainer emulator = new AzuriteContainer("mcr.microsoft.com/azure-storage/azurite:3.33.0") + .withSsl( + MountableFile.forClasspathResource("/certificate.pem"), + MountableFile.forClasspathResource("/key.pem") + ) + ) { + emulator.start(); + assertThat(emulator.getConnectionString()).contains("BlobEndpoint=https://"); + testBlob(emulator); + } + } + + @Test + void testWithQueueServiceClientWithSslUsingPem() { + try ( + AzuriteContainer emulator = new AzuriteContainer("mcr.microsoft.com/azure-storage/azurite:3.33.0") + .withSsl( + MountableFile.forClasspathResource("/certificate.pem"), + MountableFile.forClasspathResource("/key.pem") + ) + ) { + emulator.start(); + assertThat(emulator.getConnectionString()).contains("QueueEndpoint=https://"); + testQueue(emulator); + } + } + + @Test + void testWithTableServiceClientWithSslUsingPem() { + try ( + AzuriteContainer emulator = new AzuriteContainer("mcr.microsoft.com/azure-storage/azurite:3.33.0") + .withSsl( + MountableFile.forClasspathResource("/certificate.pem"), + MountableFile.forClasspathResource("/key.pem") + ) + ) { + emulator.start(); + assertThat(emulator.getConnectionString()).contains("TableEndpoint=https://"); + testTable(emulator); + } + } + + @Test + void testTwoAccountKeysWithBlobServiceClient() { + try ( + // withTwoAccountKeys { + AzuriteContainer emulator = new AzuriteContainer("mcr.microsoft.com/azure-storage/azurite:3.33.0") + .withEnv("AZURITE_ACCOUNTS", "account1:key1:key2") + // } + ) { + emulator.start(); + + String connectionString1 = emulator.getConnectionString("account1", "key1"); + // the second account will have access to the same container using a different key + String connectionString2 = emulator.getConnectionString("account1", "key2"); + + BlobServiceClient blobServiceClient1 = new BlobServiceClientBuilder() + .connectionString(connectionString1) + .buildClient(); + + BlobContainerClient containerClient1 = blobServiceClient1.createBlobContainer("test-container"); + BlobClient blobClient1 = containerClient1.getBlobClient("test-blob.txt"); + blobClient1.upload(BinaryData.fromString("content")); + boolean existsWithAccount1 = blobClient1.exists(); + String contentWithAccount1 = blobClient1.downloadContent().toString(); + + BlobServiceClient blobServiceClient2 = new BlobServiceClientBuilder() + .connectionString(connectionString2) + .buildClient(); + BlobContainerClient containerClient2 = blobServiceClient2.getBlobContainerClient("test-container"); + BlobClient blobClient2 = containerClient2.getBlobClient("test-blob.txt"); + boolean existsWithAccount2 = blobClient2.exists(); + String contentWithAccount2 = blobClient2.downloadContent().toString(); + + assertThat(existsWithAccount1).isTrue(); + assertThat(contentWithAccount1).isEqualTo("content"); + assertThat(existsWithAccount2).isTrue(); + assertThat(contentWithAccount2).isEqualTo("content"); + } + } + + @Test + void testMultipleAccountsWithBlobServiceClient() { + try ( + // withMoreAccounts { + AzuriteContainer emulator = new AzuriteContainer("mcr.microsoft.com/azure-storage/azurite:3.33.0") + .withEnv("AZURITE_ACCOUNTS", "account1:key1;account2:key2") + // } + ) { + emulator.start(); + + // useNonDefaultCredentials { + String connectionString1 = emulator.getConnectionString("account1", "key1"); + // the second account will not have access to the same container + String connectionString2 = emulator.getConnectionString("account2", "key2"); + // } + BlobServiceClient blobServiceClient1 = new BlobServiceClientBuilder() + .connectionString(connectionString1) + .buildClient(); + + BlobContainerClient containerClient1 = blobServiceClient1.createBlobContainer("test-container"); + BlobClient blobClient1 = containerClient1.getBlobClient("test-blob.txt"); + blobClient1.upload(BinaryData.fromString("content")); + boolean existsWithAccount1 = blobClient1.exists(); + String contentWithAccount1 = blobClient1.downloadContent().toString(); + + BlobServiceClient blobServiceClient2 = new BlobServiceClientBuilder() + .connectionString(connectionString2) + .buildClient(); + BlobContainerClient containerClient2 = blobServiceClient2.createBlobContainer("test-container"); + BlobClient blobClient2 = containerClient2.getBlobClient("test-blob.txt"); + boolean existsWithAccount2 = blobClient2.exists(); + + assertThat(existsWithAccount1).isTrue(); + assertThat(contentWithAccount1).isEqualTo("content"); + assertThat(existsWithAccount2).isFalse(); + } + } + + private void testBlob(AzuriteContainer container) { + // createBlobClient { + BlobServiceClient blobServiceClient = new BlobServiceClientBuilder() + .connectionString(container.getConnectionString()) + .buildClient(); + // } + BlobContainerClient containerClient = blobServiceClient.createBlobContainer("test-container"); + + assertThat(containerClient.exists()).isTrue(); + } + + private void testQueue(AzuriteContainer container) { + // createQueueClient { + QueueServiceClient queueServiceClient = new QueueServiceClientBuilder() + .connectionString(container.getConnectionString()) + .buildClient(); + // } + QueueClient queueClient = queueServiceClient.createQueue("test-queue"); + + assertThat(queueClient.getQueueUrl()).isNotNull(); + } + + private void testTable(AzuriteContainer container) { + // createTableClient { + TableServiceClient tableServiceClient = new TableServiceClientBuilder() + .connectionString(container.getConnectionString()) + .buildClient(); + // } + TableClient tableClient = tableServiceClient.createTable("testtable"); + + assertThat(tableClient.getTableEndpoint()).isNotNull(); + } +} diff --git a/modules/azure/src/test/java/org/testcontainers/azure/EventHubsEmulatorContainerTest.java b/modules/azure/src/test/java/org/testcontainers/azure/EventHubsEmulatorContainerTest.java new file mode 100644 index 00000000000..1191c980edd --- /dev/null +++ b/modules/azure/src/test/java/org/testcontainers/azure/EventHubsEmulatorContainerTest.java @@ -0,0 +1,74 @@ +package org.testcontainers.azure; + +import com.azure.core.util.IterableStream; +import com.azure.messaging.eventhubs.EventData; +import com.azure.messaging.eventhubs.EventHubClientBuilder; +import com.azure.messaging.eventhubs.EventHubConsumerClient; +import com.azure.messaging.eventhubs.EventHubProducerClient; +import com.azure.messaging.eventhubs.models.EventPosition; +import com.azure.messaging.eventhubs.models.PartitionEvent; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.Network; +import org.testcontainers.utility.MountableFile; + +import java.time.Duration; +import java.util.Collections; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.waitAtMost; + +class EventHubsEmulatorContainerTest { + + @Test + public void testWithEventHubsClient() { + try ( + // network { + Network network = Network.newNetwork(); + // } + // azuriteContainer { + AzuriteContainer azuriteContainer = new AzuriteContainer("mcr.microsoft.com/azure-storage/azurite:3.33.0") + .withNetwork(network); + // } + // emulatorContainer { + EventHubsEmulatorContainer emulator = new EventHubsEmulatorContainer( + "mcr.microsoft.com/azure-messaging/eventhubs-emulator:2.0.1" + ) + .acceptLicense() + .withNetwork(network) + .withConfig(MountableFile.forClasspathResource("/eventhubs_config.json")) + .withAzuriteContainer(azuriteContainer); + // } + ) { + emulator.start(); + // createProducerAndConsumer { + EventHubProducerClient producer = new EventHubClientBuilder() + .connectionString(emulator.getConnectionString()) + .fullyQualifiedNamespace("emulatorNs1") + .eventHubName("eh1") + .buildProducerClient(); + EventHubConsumerClient consumer = new EventHubClientBuilder() + .connectionString(emulator.getConnectionString()) + .fullyQualifiedNamespace("emulatorNs1") + .eventHubName("eh1") + .consumerGroup("cg1") + .buildConsumerClient(); + // } + producer.send(Collections.singletonList(new EventData("test"))); + + waitAtMost(Duration.ofSeconds(30)) + .pollDelay(Duration.ofSeconds(5)) + .untilAsserted(() -> { + IterableStream events = consumer.receiveFromPartition( + "0", + 1, + EventPosition.earliest(), + Duration.ofSeconds(2) + ); + Optional event = events.stream().findFirst(); + assertThat(event).isPresent(); + assertThat(event.get().getData().getBodyAsString()).isEqualTo("test"); + }); + } + } +} diff --git a/modules/azure/src/test/java/org/testcontainers/azure/ServiceBusEmulatorContainerTest.java b/modules/azure/src/test/java/org/testcontainers/azure/ServiceBusEmulatorContainerTest.java new file mode 100644 index 00000000000..83cd633c5a9 --- /dev/null +++ b/modules/azure/src/test/java/org/testcontainers/azure/ServiceBusEmulatorContainerTest.java @@ -0,0 +1,99 @@ +package org.testcontainers.azure; + +import com.azure.messaging.servicebus.ServiceBusClientBuilder; +import com.azure.messaging.servicebus.ServiceBusErrorContext; +import com.azure.messaging.servicebus.ServiceBusException; +import com.azure.messaging.servicebus.ServiceBusMessage; +import com.azure.messaging.servicebus.ServiceBusProcessorClient; +import com.azure.messaging.servicebus.ServiceBusReceivedMessageContext; +import com.azure.messaging.servicebus.ServiceBusSenderClient; +import com.github.dockerjava.api.model.Capability; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.Network; +import org.testcontainers.mssqlserver.MSSQLServerContainer; +import org.testcontainers.utility.MountableFile; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class ServiceBusEmulatorContainerTest { + + @Test + void testWithClient() { + try ( + // network { + Network network = Network.newNetwork(); + // } + // sqlContainer { + MSSQLServerContainer mssqlServerContainer = new MSSQLServerContainer( + "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04" + ) + .acceptLicense() + .withPassword("yourStrong(!)Password") + .withCreateContainerCmdModifier(cmd -> { + cmd.getHostConfig().withCapAdd(Capability.SYS_PTRACE); + }) + .withNetwork(network); + // } + // emulatorContainer { + ServiceBusEmulatorContainer emulator = new ServiceBusEmulatorContainer( + "mcr.microsoft.com/azure-messaging/servicebus-emulator:1.1.2" + ) + .acceptLicense() + .withConfig(MountableFile.forClasspathResource("/service-bus-config.json")) + .withNetwork(network) + .withMsSqlServerContainer(mssqlServerContainer); + // } + ) { + emulator.start(); + assertThat(emulator.getConnectionString()).startsWith("Endpoint=sb://"); + + // senderClient { + ServiceBusSenderClient senderClient = new ServiceBusClientBuilder() + .connectionString(emulator.getConnectionString()) + .sender() + .queueName("queue.1") + .buildClient(); + // } + + await() + .atMost(20, TimeUnit.SECONDS) + .ignoreException(ServiceBusException.class) + .until(() -> { + senderClient.sendMessage(new ServiceBusMessage("Hello, Testcontainers!")); + return true; + }); + senderClient.close(); + + final List received = new CopyOnWriteArrayList<>(); + Consumer messageConsumer = m -> { + received.add(m.getMessage().getBody().toString()); + m.complete(); + }; + Consumer errorConsumer = e -> Assertions.fail("Unexpected error: " + e); + // processorClient { + ServiceBusProcessorClient processorClient = new ServiceBusClientBuilder() + .connectionString(emulator.getConnectionString()) + .processor() + .queueName("queue.1") + .processMessage(messageConsumer) + .processError(errorConsumer) + .buildProcessorClient(); + // } + processorClient.start(); + + await() + .atMost(20, TimeUnit.SECONDS) + .untilAsserted(() -> { + assertThat(received).hasSize(1).containsExactlyInAnyOrder("Hello, Testcontainers!"); + }); + processorClient.close(); + } + } +} diff --git a/modules/azure/src/test/java/org/testcontainers/containers/CosmosDBEmulatorContainerTest.java b/modules/azure/src/test/java/org/testcontainers/containers/CosmosDBEmulatorContainerTest.java new file mode 100644 index 00000000000..92b5814641e --- /dev/null +++ b/modules/azure/src/test/java/org/testcontainers/containers/CosmosDBEmulatorContainerTest.java @@ -0,0 +1,76 @@ +package org.testcontainers.containers; + +import com.azure.cosmos.CosmosAsyncClient; +import com.azure.cosmos.CosmosClientBuilder; +import com.azure.cosmos.models.CosmosContainerResponse; +import com.azure.cosmos.models.CosmosDatabaseResponse; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.testcontainers.utility.DockerImageName; + +import java.io.FileOutputStream; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +class CosmosDBEmulatorContainerTest { + + private static Properties originalSystemProperties; + + @BeforeAll + public static void captureOriginalSystemProperties() { + originalSystemProperties = (Properties) System.getProperties().clone(); + } + + @AfterAll + public static void restoreOriginalSystemProperties() { + System.setProperties(originalSystemProperties); + } + + @TempDir + public Path tempFolder; + + @Test + void testWithCosmosClient() throws Exception { + try ( + // emulatorContainer { + CosmosDBEmulatorContainer emulator = new CosmosDBEmulatorContainer( + DockerImageName.parse("mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest") + ); + // } + ) { + emulator.start(); + // buildAndSaveNewKeyStore { + Path keyStoreFile = tempFolder.resolve("azure-cosmos-emulator.keystore"); + KeyStore keyStore = emulator.buildNewKeyStore(); + keyStore.store(new FileOutputStream(keyStoreFile.toFile()), emulator.getEmulatorKey().toCharArray()); + // } + // setSystemTrustStoreParameters { + System.setProperty("javax.net.ssl.trustStore", keyStoreFile.toString()); + System.setProperty("javax.net.ssl.trustStorePassword", emulator.getEmulatorKey()); + System.setProperty("javax.net.ssl.trustStoreType", "PKCS12"); + // } + // buildClient { + CosmosAsyncClient client = new CosmosClientBuilder() + .gatewayMode() + .endpointDiscoveryEnabled(false) + .endpoint(emulator.getEmulatorEndpoint()) + .key(emulator.getEmulatorKey()) + .buildAsyncClient(); + // } + // testWithClientAgainstEmulatorContainer { + CosmosDatabaseResponse databaseResponse = client.createDatabaseIfNotExists("Azure").block(); + assertThat(databaseResponse.getStatusCode()).isEqualTo(201); + CosmosContainerResponse containerResponse = client + .getDatabase("Azure") + .createContainerIfNotExists("ServiceContainer", "/name") + .block(); + assertThat(containerResponse.getStatusCode()).isEqualTo(201); + // } + } + } +} diff --git a/modules/azure/src/test/resources/certificate.pem b/modules/azure/src/test/resources/certificate.pem new file mode 100644 index 00000000000..30bedc29f45 --- /dev/null +++ b/modules/azure/src/test/resources/certificate.pem @@ -0,0 +1,23 @@ +Bag Attributes + friendlyName: localhost + localKeyID: 54 69 6D 65 20 31 37 33 34 37 32 32 33 32 31 33 31 39 +subject=CN = localhost +issuer=CN = localhost +-----BEGIN CERTIFICATE----- +MIIC5zCCAc+gAwIBAgIILe7i2bhRE5cwDQYJKoZIhvcNAQEMBQAwFDESMBAGA1UE +AxMJbG9jYWxob3N0MB4XDTI0MTIyMDE5MTg0MVoXDTQ0MTIxNTE5MTg0MVowFDES +MBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAoqYNmLl8IiIoYrXdcoWiMQaM0lOHcV9v3A/THMremHxsR+JPm3FIOAuilFcy +my16kuXIWHfisPxUWr9Vbf8wP/WwZutoOofJrqmruZoorQcNLCs8mQweguRmL1ju +/lDh/9bP626vP9OjwStC4UO4f8Jga8ENoH1U+j1RsIAswYnkk3YIN6YrYv66UvtH +IfR0ERgid2LMBIM+2KD2zw4QRyqXH7Qvo7sCsxdYYHGa6GXfza4vgvce9kJwGqn5 +wiF0Uw9XQbr/LarnR2GCy020OB81KweQJNIh27FZSRLtT+XpsjDRcC2aLBd8CRHd +hwO2zAPI04dLbLM5XAHlEdfT7wIDAQABoz0wOzAdBgNVHQ4EFgQUPqY5isb6Q11Q +t6dbXYHEupxADdMwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3 +DQEBDAUAA4IBAQA2katMXrTJBukiNh9yceLO/MewsxvU3KOO/O89ngfjhKXm9T8E +RtENCmp7hLbj1Aj4PRZx3AbmUt9+tRu8fmrRXJQWgUDSHJWjDwSTBOaHcC5LDWSU +Ex4co5Mnxvrimg7tqQg82Hw/yLH9j6gyTyh6v45QETP7IUkTZe4fg75/kPjng7Xg +wp/QXFUx/f0dbvGRl2Fdgg0SnYFqHS3MFIjjFjv8SQlV7rZe+CD1Lxqy/Z6Fd/Fa +33TzTuJeSAG43vdkGAvsNK/KdnxAW03T4l3pVHpNPcvsIvJUMeKOwYOjwHF/eowk +tGrKbpUYFxUr9iKHTfu14t1oExhAsnda2Fcs +-----END CERTIFICATE----- diff --git a/modules/azure/src/test/resources/eventhubs_config.json b/modules/azure/src/test/resources/eventhubs_config.json new file mode 100644 index 00000000000..554be9d7cbf --- /dev/null +++ b/modules/azure/src/test/resources/eventhubs_config.json @@ -0,0 +1,24 @@ +{ + "UserConfig": { + "NamespaceConfig": [ + { + "Type": "EventHub", + "Name": "emulatorNs1", + "Entities": [ + { + "Name": "eh1", + "PartitionCount": "1", + "ConsumerGroups": [ + { + "Name": "cg1" + } + ] + } + ] + } + ], + "LoggingConfig": { + "Type": "File" + } + } +} diff --git a/modules/azure/src/test/resources/key.pem b/modules/azure/src/test/resources/key.pem new file mode 100644 index 00000000000..7c635f5a278 --- /dev/null +++ b/modules/azure/src/test/resources/key.pem @@ -0,0 +1,32 @@ +Bag Attributes + friendlyName: localhost + localKeyID: 54 69 6D 65 20 31 37 33 34 37 32 32 33 32 31 33 31 39 +Key Attributes: +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCipg2YuXwiIihi +td1yhaIxBozSU4dxX2/cD9Mcyt6YfGxH4k+bcUg4C6KUVzKbLXqS5chYd+Kw/FRa +v1Vt/zA/9bBm62g6h8muqau5miitBw0sKzyZDB6C5GYvWO7+UOH/1s/rbq8/06PB +K0LhQ7h/wmBrwQ2gfVT6PVGwgCzBieSTdgg3piti/rpS+0ch9HQRGCJ3YswEgz7Y +oPbPDhBHKpcftC+juwKzF1hgcZroZd/Nri+C9x72QnAaqfnCIXRTD1dBuv8tqudH +YYLLTbQ4HzUrB5Ak0iHbsVlJEu1P5emyMNFwLZosF3wJEd2HA7bMA8jTh0tsszlc +AeUR19PvAgMBAAECggEAFT8dzZKFTawqnGJncBtWyZKyeJMiwUOXSCblDADQPRkb +x/QfNA4DQhb7AOe3G6BAP8o2dqAKg9YiasxNq5XHRsOgbIFZ1zN/vAo7/X3OzHN8 +XAW138Q+hBiz5IF4js4gB5yXAokt6WeLH6O4E9cV1dKdZ9YLIqjcnee+sRC9R/a3 +CexqLfC6b77JFbtePfq+5cn2RiK540tO/4k+F+kfJtTg78Wf2RB3A0pBAunhPSd5 +eyjiSvOZtTcvl4GdYw9nKf24I1/WUvt9FH/r1XG0CM5iwuGodbBz1iSUDaQGi5Lf +hFWofXt7eebgsPEKciG4xTyk51p9fy9y8asY+jCbkQKBgQDG2UqJToR8G4Fk6uaO +/XJa15TibIwQDEota0OdlXg2ZR4864fkIBv+UTbymZEM/EBuSdMM1CUBDvYHcFQX +Aj8p1LUyKP2QYwxV/OoPfJ5fBqxqONNR1fLFg7xCxnf9kSvsni2WFneQUrTDl8+7 +qnHm4IKPkAxZ4Orxl5qIBmlpGQKBgQDRZUL3cHIVLLg/aZACpo6SYDYg2bztXmz4 +lRk9j17q1uS83Umzd2lPFmSt/Nr85EKraxXZ/lYPKrP/r1pf1/35eXOWqmYBWgo/ +Hh7OzL12bhvv9UWEY/TvW+wNJNtXlJSjEFRN4tjoG2amYumyhwMO1lIulplUWvtw +ymm8hDjeRwKBgCq7n60KVqZlMtWBNbMc/GpRUgmm0iLQwVApcQp4iLEH4gutgjKg +Q+PPiENyhR2JSD9rVhO3s4warvzCQw/+x5wxvg7diEBzSL9h7tsNKOu6/2qEc8Vu +eRHBUb/37ulrPUlIZPuQMHmvjHFMOrRV2MyJCwXXKxBVqafpsKfy2MxhAoGBAIHH +Cswk6u/ouYDDwjeCVxatfp65lHhhb5RZhD09IIzYBwhu9gC+34veyyNydZ8LMa7g +PbjQAzJ/OvQbEB4a1hPKjDMzBOmNjpAz8NAm4L4H3FTKZP16nhHDnPdAgpkzQzQV +KMrk755bbTFuWH0HZIPLnT+2ou0/PltXeFUYdc59AoGBAIGfWgSOiw7aXbSQZFrO +4S0v3VTwTaiGDVS4pkNRLlhEJUhy8+gbLv/zYDmFmGtqVhXTb/nd6DOdylp+W/HS +8xNWBMWdlX/hVdSK7M0TdJvAaCaMidlquf5qZ2tGNNDeTUN1qbRH26pm8vdNZ3gr +Y/WWJGo0iEmwyB8RcFhvNmuJ +-----END PRIVATE KEY----- diff --git a/modules/azure/src/test/resources/keystore.pfx b/modules/azure/src/test/resources/keystore.pfx new file mode 100644 index 00000000000..3fd2975d3e8 Binary files /dev/null and b/modules/azure/src/test/resources/keystore.pfx differ diff --git a/modules/azure/src/test/resources/logback-test.xml b/modules/azure/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/azure/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/azure/src/test/resources/service-bus-config.json b/modules/azure/src/test/resources/service-bus-config.json new file mode 100644 index 00000000000..18ac2e69c7b --- /dev/null +++ b/modules/azure/src/test/resources/service-bus-config.json @@ -0,0 +1,29 @@ +{ + "UserConfig": { + "Namespaces": [ + { + "Name": "sbemulatorns", + "Queues": [ + { + "Name": "queue.1", + "Properties": { + "DeadLetteringOnMessageExpiration": false, + "DefaultMessageTimeToLive": "PT1H", + "DuplicateDetectionHistoryTimeWindow": "PT20S", + "ForwardDeadLetteredMessagesTo": "", + "ForwardTo": "", + "LockDuration": "PT1M", + "MaxDeliveryCount": 3, + "RequiresDuplicateDetection": false, + "RequiresSession": false + } + } + ], + "Topics": [] + } + ], + "Logging": { + "Type": "File" + } + } +} diff --git a/modules/build.gradle b/modules/build.gradle new file mode 100644 index 00000000000..e69de29bb2d diff --git a/modules/cassandra/build.gradle b/modules/cassandra/build.gradle new file mode 100644 index 00000000000..b0bea00226e --- /dev/null +++ b/modules/cassandra/build.gradle @@ -0,0 +1,14 @@ +description = "Testcontainers :: Cassandra" + +configurations.all { + resolutionStrategy { + force 'io.dropwizard.metrics:metrics-core:3.2.6' + } +} + +dependencies { + api project(":testcontainers-database-commons") + api "com.datastax.cassandra:cassandra-driver-core:3.10.0" + + testImplementation 'com.datastax.oss:java-driver-core:4.17.0' +} diff --git a/modules/cassandra/src/main/java/org/testcontainers/cassandra/CassandraContainer.java b/modules/cassandra/src/main/java/org/testcontainers/cassandra/CassandraContainer.java new file mode 100644 index 00000000000..3fb8554f137 --- /dev/null +++ b/modules/cassandra/src/main/java/org/testcontainers/cassandra/CassandraContainer.java @@ -0,0 +1,211 @@ +package org.testcontainers.cassandra; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import org.apache.commons.lang3.StringUtils; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.ext.ScriptUtils; +import org.testcontainers.ext.ScriptUtils.ScriptLoadException; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.net.InetSocketAddress; +import java.util.Optional; + +/** + * Testcontainers implementation for Apache Cassandra. + *

+ * Supported image: {@code cassandra} + *

+ * Exposed ports: 9042 + */ +public class CassandraContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("cassandra"); + + private static final Integer CQL_PORT = 9042; + + private static final String DEFAULT_LOCAL_DATACENTER = "datacenter1"; + + private static final String DEFAULT_INIT_SCRIPT_FILENAME = "init.cql"; + + private static final String CONTAINER_CONFIG_LOCATION = "/etc/cassandra"; + + private static final String USERNAME = "cassandra"; + + private static final String PASSWORD = "cassandra"; + + private String configLocation; + + private String initScriptPath; + + private String clientCertFile; + + private String clientKeyFile; + + public CassandraContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public CassandraContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + addExposedPort(CQL_PORT); + + withEnv("CASSANDRA_SNITCH", "GossipingPropertyFileSnitch"); + withEnv("JVM_OPTS", "-Dcassandra.skip_wait_for_gossip_to_settle=0 -Dcassandra.initial_token=0"); + withEnv("HEAP_NEWSIZE", "128M"); + withEnv("MAX_HEAP_SIZE", "1024M"); + withEnv("CASSANDRA_ENDPOINT_SNITCH", "GossipingPropertyFileSnitch"); + withEnv("CASSANDRA_DC", DEFAULT_LOCAL_DATACENTER); + + // Use the CassandraQueryWaitStrategy by default to avoid potential issues when the authentication is enabled. + waitingFor(new CassandraQueryWaitStrategy()); + } + + @Override + protected void configure() { + // Map (effectively replace) directory in Docker with the content of resourceLocation if resource location is + // not null. + Optional + .ofNullable(configLocation) + .map(MountableFile::forClasspathResource) + .ifPresent(mountableFile -> withCopyFileToContainer(mountableFile, CONTAINER_CONFIG_LOCATION)); + + // If a secure connection is required by Cassandra configuration, copy the user certificate and key to a + // dedicated location and define a cqlshrc file with the appropriate SSL configuration. + // See: https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureCqlshSSL.html + if (isSslRequired()) { + withCopyFileToContainer(MountableFile.forClasspathResource(clientCertFile), "ssl/user_cert.pem"); + withCopyFileToContainer(MountableFile.forClasspathResource(clientKeyFile), "ssl/user_key.pem"); + withCopyFileToContainer(MountableFile.forClasspathResource("cqlshrc"), "/root/.cassandra/cqlshrc"); + } + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + runInitScriptIfRequired(); + } + + /** + * Load init script content and apply it to the database if initScriptPath is set + */ + private void runInitScriptIfRequired() { + if (this.initScriptPath != null) { + try { + final MountableFile originalInitScript = MountableFile.forClasspathResource(this.initScriptPath); + // The init script is executed as is by the cqlsh command, so copy it into the container. The name + // of the script is generic since it's not important to keep the original name. + copyFileToContainer(originalInitScript, DEFAULT_INIT_SCRIPT_FILENAME); + new CassandraDatabaseDelegate(this).execute(null, DEFAULT_INIT_SCRIPT_FILENAME, -1, false, false); + } catch (IllegalArgumentException e) { + // MountableFile.forClasspathResource will throw an IllegalArgumentException if the resource cannot + // be found. + logger().warn("Could not load classpath init script: {}", this.initScriptPath); + throw new ScriptLoadException( + "Could not load classpath init script: " + this.initScriptPath + ". Resource not found.", + e + ); + } catch (ScriptUtils.ScriptStatementFailedException e) { + logger().error("Error while executing init script: {}", this.initScriptPath, e); + throw new ScriptUtils.UncategorizedScriptException( + "Error while executing init script: " + this.initScriptPath, + e + ); + } + } + } + + /** + * Initialize Cassandra with the custom overridden Cassandra configuration + *

+ * Be aware, that Docker effectively replaces all /etc/cassandra content with the content of config location, so if + * Cassandra.yaml in configLocation is absent or corrupted, then Cassandra just won't launch. + * + * @param configLocation relative classpath with the directory that contains cassandra.yaml and other configuration + * files + * @return The updated {@link CassandraContainer}. + */ + public CassandraContainer withConfigurationOverride(String configLocation) { + this.configLocation = configLocation; + return self(); + } + + /** + * Initialize Cassandra with init CQL script + *

+ * CQL script will be applied after container is started (see using WaitStrategy). + *

+ * + * @param initScriptPath relative classpath resource + * @return The updated {@link CassandraContainer}. + */ + public CassandraContainer withInitScript(String initScriptPath) { + this.initScriptPath = initScriptPath; + return self(); + } + + /** + * Configure secured connection (TLS) when required by the Cassandra configuration + * (i.e. cassandra.yaml file contains the property {@code client_encryption_options.optional} with value + * {@code false}). + * + * @param clientCertFile The client certificate required to execute CQL scripts. + * @param clientKeyFile The client key required to execute CQL scripts. + * @return The updated {@link CassandraContainer}. + */ + public CassandraContainer withSsl(String clientCertFile, String clientKeyFile) { + this.clientCertFile = clientCertFile; + this.clientKeyFile = clientKeyFile; + return self(); + } + + /** + * @return Whether a secure connection is required between the client and the Cassandra server. + */ + boolean isSslRequired() { + return StringUtils.isNoneBlank(this.clientCertFile, this.clientKeyFile); + } + + /** + * Get username + *

+ * By default, Cassandra has authenticator: AllowAllAuthenticator in cassandra.yaml + * If username and password need to be used, then authenticator should be set as PasswordAuthenticator + * (through custom Cassandra configuration) and through CQL with default cassandra-cassandra credentials + * user management should be modified + */ + public String getUsername() { + return USERNAME; + } + + /** + * Get password + *

+ * By default, Cassandra has authenticator: AllowAllAuthenticator in cassandra.yaml + * If username and password need to be used, then authenticator should be set as PasswordAuthenticator + * (through custom Cassandra configuration) and through CQL with default cassandra-cassandra credentials + * user management should be modified + */ + public String getPassword() { + return PASSWORD; + } + + /** + * Retrieve an {@link InetSocketAddress} for connecting to the Cassandra container via the driver. + * + * @return A InetSocketAddress representation of this Cassandra container's host and port. + */ + public InetSocketAddress getContactPoint() { + return new InetSocketAddress(getHost(), getMappedPort(CQL_PORT)); + } + + /** + * Retrieve the Local Datacenter for connecting to the Cassandra container via the driver. + * + * @return The configured local Datacenter name. + */ + public String getLocalDatacenter() { + return getEnvMap().getOrDefault("CASSANDRA_DC", DEFAULT_LOCAL_DATACENTER); + } +} diff --git a/modules/cassandra/src/main/java/org/testcontainers/cassandra/CassandraDatabaseDelegate.java b/modules/cassandra/src/main/java/org/testcontainers/cassandra/CassandraDatabaseDelegate.java new file mode 100644 index 00000000000..4867b8423e9 --- /dev/null +++ b/modules/cassandra/src/main/java/org/testcontainers/cassandra/CassandraDatabaseDelegate.java @@ -0,0 +1,98 @@ +package org.testcontainers.cassandra; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.ContainerState; +import org.testcontainers.containers.ExecConfig; +import org.testcontainers.delegate.AbstractDatabaseDelegate; +import org.testcontainers.ext.ScriptUtils.ScriptStatementFailedException; + +import java.io.IOException; + +/** + * Cassandra database delegate + */ +@Slf4j +@RequiredArgsConstructor +public class CassandraDatabaseDelegate extends AbstractDatabaseDelegate { + + private final ContainerState container; + + @Override + protected Void createNewConnection() { + // Return null here, because we run scripts using cqlsh command directly in the container. + // So, we don't use connection object in the execute() method. + return null; + } + + public void execute( + String statement, + String scriptPath, + int lineNumber, + boolean continueOnError, + boolean ignoreFailedDrops, + boolean silentErrorLogs + ) { + try { + // Use cqlsh command directly inside the container to execute statements + // See documentation here: https://cassandra.apache.org/doc/stable/cassandra/tools/cqlsh.html + String[] cqlshCommand = new String[] { "cqlsh" }; + + if (this.container instanceof CassandraContainer) { + CassandraContainer cassandraContainer = (CassandraContainer) this.container; + String username = cassandraContainer.getUsername(); + String password = cassandraContainer.getPassword(); + if (cassandraContainer.isSslRequired()) { + cqlshCommand = ArrayUtils.add(cqlshCommand, "--ssl"); + } + cqlshCommand = ArrayUtils.addAll(cqlshCommand, "-u", username, "-p", password); + } + + // If no statement specified, directly execute the script specified into scriptPath (using -f argument), + // otherwise execute the given statement (using -e argument). + String executeArg = "-e"; + String executeArgValue = statement; + if (StringUtils.isBlank(statement)) { + executeArg = "-f"; + executeArgValue = scriptPath; + } + cqlshCommand = ArrayUtils.addAll(cqlshCommand, executeArg, executeArgValue); + + Container.ExecResult result = + this.container.execInContainer(ExecConfig.builder().command(cqlshCommand).build()); + if (result.getExitCode() == 0) { + if (StringUtils.isBlank(statement)) { + log.info("CQL script {} successfully executed", scriptPath); + } else { + log.info("CQL statement {} was applied", statement); + } + } else { + if (!silentErrorLogs) { + log.error("CQL script execution failed with error: \n{}", result.getStderr()); + } + throw new ScriptStatementFailedException(statement, lineNumber, scriptPath); + } + } catch (IOException | InterruptedException e) { + throw new ScriptStatementFailedException(statement, lineNumber, scriptPath, e); + } + } + + @Override + public void execute( + String statement, + String scriptPath, + int lineNumber, + boolean continueOnError, + boolean ignoreFailedDrops + ) { + this.execute(statement, scriptPath, lineNumber, continueOnError, ignoreFailedDrops, false); + } + + @Override + protected void closeConnectionQuietly(Void session) { + // Nothing to do here, because we run scripts using cqlsh command directly in the container. + } +} diff --git a/modules/cassandra/src/main/java/org/testcontainers/cassandra/CassandraQueryWaitStrategy.java b/modules/cassandra/src/main/java/org/testcontainers/cassandra/CassandraQueryWaitStrategy.java new file mode 100644 index 00000000000..19fdcd7f9e1 --- /dev/null +++ b/modules/cassandra/src/main/java/org/testcontainers/cassandra/CassandraQueryWaitStrategy.java @@ -0,0 +1,57 @@ +package org.testcontainers.cassandra; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.rnorth.ducttape.TimeoutException; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; +import org.testcontainers.delegate.DatabaseDelegate; + +import java.util.concurrent.TimeUnit; + +import static org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess; + +/** + * Waits until Cassandra returns its version + */ +@Slf4j +public class CassandraQueryWaitStrategy extends AbstractWaitStrategy { + + private static final String SELECT_VERSION_QUERY = "SELECT release_version FROM system.local"; + + private static final String TIMEOUT_ERROR = "Timed out waiting for Cassandra to be accessible for query execution"; + + @Override + protected void waitUntilReady() { + // execute select version query until success or timeout + try { + retryUntilSuccess( + (int) startupTimeout.getSeconds(), + TimeUnit.SECONDS, + () -> { + getRateLimiter() + .doWhenReady(() -> { + try (DatabaseDelegate databaseDelegate = getDatabaseDelegate()) { + log.info("Checking connection is ready..."); + ((CassandraDatabaseDelegate) databaseDelegate).execute( + SELECT_VERSION_QUERY, + StringUtils.EMPTY, + 1, + false, + false, + true + ); + } + }); + return true; + } + ); + } catch (TimeoutException e) { + throw new ContainerLaunchException(TIMEOUT_ERROR); + } + } + + private DatabaseDelegate getDatabaseDelegate() { + return new CassandraDatabaseDelegate(waitStrategyTarget); + } +} diff --git a/modules/cassandra/src/main/java/org/testcontainers/containers/CassandraContainer.java b/modules/cassandra/src/main/java/org/testcontainers/containers/CassandraContainer.java new file mode 100644 index 00000000000..fcaada177ce --- /dev/null +++ b/modules/cassandra/src/main/java/org/testcontainers/containers/CassandraContainer.java @@ -0,0 +1,250 @@ +package org.testcontainers.containers; + +import com.datastax.driver.core.Cluster; +import com.github.dockerjava.api.command.InspectContainerResponse; +import org.apache.commons.io.IOUtils; +import org.testcontainers.containers.delegate.CassandraDatabaseDelegate; +import org.testcontainers.delegate.DatabaseDelegate; +import org.testcontainers.ext.ScriptUtils; +import org.testcontainers.ext.ScriptUtils.ScriptLoadException; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import javax.script.ScriptException; + +/** + * Testcontainers implementation for Apache Cassandra. + *

+ * Supported image: {@code cassandra} + *

+ * Exposed ports: 9042 + * + * @deprecated use {@link org.testcontainers.cassandra.CassandraContainer} instead. + */ +@Deprecated +public class CassandraContainer> extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("cassandra"); + + private static final String DEFAULT_TAG = "3.11.2"; + + @Deprecated + public static final String IMAGE = DEFAULT_IMAGE_NAME.getUnversionedPart(); + + public static final Integer CQL_PORT = 9042; + + private static final String DEFAULT_LOCAL_DATACENTER = "datacenter1"; + + private static final String CONTAINER_CONFIG_LOCATION = "/etc/cassandra"; + + private static final String USERNAME = "cassandra"; + + private static final String PASSWORD = "cassandra"; + + private String configLocation; + + private String initScriptPath; + + private boolean enableJmxReporting; + + /** + * @deprecated use {@link #CassandraContainer(DockerImageName)} instead + */ + @Deprecated + public CassandraContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + public CassandraContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public CassandraContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + addExposedPort(CQL_PORT); + this.enableJmxReporting = false; + + withEnv("CASSANDRA_SNITCH", "GossipingPropertyFileSnitch"); + withEnv("JVM_OPTS", "-Dcassandra.skip_wait_for_gossip_to_settle=0 -Dcassandra.initial_token=0"); + withEnv("HEAP_NEWSIZE", "128M"); + withEnv("MAX_HEAP_SIZE", "1024M"); + withEnv("CASSANDRA_ENDPOINT_SNITCH", "GossipingPropertyFileSnitch"); + withEnv("CASSANDRA_DC", DEFAULT_LOCAL_DATACENTER); + } + + @Override + protected void configure() { + optionallyMapResourceParameterAsVolume(CONTAINER_CONFIG_LOCATION, configLocation); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + runInitScriptIfRequired(); + } + + /** + * Load init script content and apply it to the database if initScriptPath is set + */ + private void runInitScriptIfRequired() { + if (initScriptPath != null) { + try { + URL resource = Thread.currentThread().getContextClassLoader().getResource(initScriptPath); + if (resource == null) { + logger().warn("Could not load classpath init script: {}", initScriptPath); + throw new ScriptLoadException( + "Could not load classpath init script: " + initScriptPath + ". Resource not found." + ); + } + String cql = IOUtils.toString(resource, StandardCharsets.UTF_8); + DatabaseDelegate databaseDelegate = getDatabaseDelegate(); + ScriptUtils.executeDatabaseScript(databaseDelegate, initScriptPath, cql); + } catch (IOException e) { + logger().warn("Could not load classpath init script: {}", initScriptPath); + throw new ScriptLoadException("Could not load classpath init script: " + initScriptPath, e); + } catch (ScriptException e) { + logger().error("Error while executing init script: {}", initScriptPath, e); + throw new ScriptUtils.UncategorizedScriptException( + "Error while executing init script: " + initScriptPath, + e + ); + } + } + } + + /** + * Map (effectively replace) directory in Docker with the content of resourceLocation if resource location is not null + * + * Protected to allow for changing implementation by extending the class + * + * @param pathNameInContainer path in docker + * @param resourceLocation relative classpath to resource + */ + protected void optionallyMapResourceParameterAsVolume(String pathNameInContainer, String resourceLocation) { + Optional + .ofNullable(resourceLocation) + .map(MountableFile::forClasspathResource) + .ifPresent(mountableFile -> withCopyFileToContainer(mountableFile, pathNameInContainer)); + } + + /** + * Initialize Cassandra with the custom overridden Cassandra configuration + *

+ * Be aware, that Docker effectively replaces all /etc/cassandra content with the content of config location, so if + * Cassandra.yaml in configLocation is absent or corrupted, then Cassandra just won't launch + * + * @param configLocation relative classpath with the directory that contains cassandra.yaml and other configuration files + */ + public SELF withConfigurationOverride(String configLocation) { + this.configLocation = configLocation; + return self(); + } + + /** + * Initialize Cassandra with init CQL script + *

+ * CQL script will be applied after container is started (see using WaitStrategy) + * + * @param initScriptPath relative classpath resource + */ + public SELF withInitScript(String initScriptPath) { + this.initScriptPath = initScriptPath; + return self(); + } + + /** + * Initialize Cassandra client with JMX reporting enabled or disabled + */ + public SELF withJmxReporting(boolean enableJmxReporting) { + this.enableJmxReporting = enableJmxReporting; + return self(); + } + + /** + * Get username + * + * By default Cassandra has authenticator: AllowAllAuthenticator in cassandra.yaml + * If username and password need to be used, then authenticator should be set as PasswordAuthenticator + * (through custom Cassandra configuration) and through CQL with default cassandra-cassandra credentials + * user management should be modified + */ + public String getUsername() { + return USERNAME; + } + + /** + * Get password + * + * By default Cassandra has authenticator: AllowAllAuthenticator in cassandra.yaml + * If username and password need to be used, then authenticator should be set as PasswordAuthenticator + * (through custom Cassandra configuration) and through CQL with default cassandra-cassandra credentials + * user management should be modified + */ + public String getPassword() { + return PASSWORD; + } + + /** + * Get configured Cluster + * + * Can be used to obtain connections to Cassandra in the container + * + * @deprecated For Cassandra driver 3.x, use {@link #getHost()} and {@link #getMappedPort(int)} with + * the driver's {@link Cluster#builder() Cluster.Builder} {@code addContactPoint(String)} and + * {@code withPort(int)} methods to create a Cluster object. For Cassandra driver 4.x, use + * {@link #getContactPoint()} and {@link #getLocalDatacenter()} with the driver's {@code CqlSession.builder()} + * {@code addContactPoint(InetSocketAddress)} and {@code withLocalDatacenter(String)} methods to create + * a Session Object. See https://docs.datastax.com/en/developer/java-driver/ for more on the driver. + */ + @Deprecated + public Cluster getCluster() { + return getCluster(this, enableJmxReporting); + } + + @Deprecated + public static Cluster getCluster(ContainerState containerState, boolean enableJmxReporting) { + final Cluster.Builder builder = Cluster + .builder() + .addContactPoint(containerState.getHost()) + .withPort(containerState.getMappedPort(CQL_PORT)); + if (!enableJmxReporting) { + builder.withoutJMXReporting(); + } + return builder.build(); + } + + @Deprecated + public static Cluster getCluster(ContainerState containerState) { + return getCluster(containerState, false); + } + + /** + * Retrieve an {@link InetSocketAddress} for connecting to the Cassandra container via the driver. + * + * @return A InetSocketAddress representation of this Cassandra container's host and port. + */ + public InetSocketAddress getContactPoint() { + return new InetSocketAddress(getHost(), getMappedPort(CQL_PORT)); + } + + /** + * Retrieve the Local Datacenter for connecting to the Cassandra container via the driver. + * + * @return The configured local Datacenter name. + */ + public String getLocalDatacenter() { + return getEnvMap().getOrDefault("CASSANDRA_DC", DEFAULT_LOCAL_DATACENTER); + } + + @Deprecated + private DatabaseDelegate getDatabaseDelegate() { + return new CassandraDatabaseDelegate(this); + } +} diff --git a/modules/cassandra/src/main/java/org/testcontainers/containers/delegate/CassandraDatabaseDelegate.java b/modules/cassandra/src/main/java/org/testcontainers/containers/delegate/CassandraDatabaseDelegate.java new file mode 100644 index 00000000000..7b7739490ea --- /dev/null +++ b/modules/cassandra/src/main/java/org/testcontainers/containers/delegate/CassandraDatabaseDelegate.java @@ -0,0 +1,64 @@ +package org.testcontainers.containers.delegate; + +import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.Session; +import com.datastax.driver.core.exceptions.DriverException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.containers.CassandraContainer; +import org.testcontainers.containers.ContainerState; +import org.testcontainers.delegate.AbstractDatabaseDelegate; +import org.testcontainers.exception.ConnectionCreationException; +import org.testcontainers.ext.ScriptUtils.ScriptStatementFailedException; + +/** + * Cassandra database delegate + * + * @deprecated use {@link org.testcontainers.cassandra.CassandraDatabaseDelegate} instead. + */ +@Slf4j +@RequiredArgsConstructor +@Deprecated +public class CassandraDatabaseDelegate extends AbstractDatabaseDelegate { + + private final ContainerState container; + + @Override + protected Session createNewConnection() { + try { + return CassandraContainer.getCluster(container).newSession(); + } catch (DriverException e) { + log.error("Could not obtain cassandra connection"); + throw new ConnectionCreationException("Could not obtain cassandra connection", e); + } + } + + @Override + public void execute( + String statement, + String scriptPath, + int lineNumber, + boolean continueOnError, + boolean ignoreFailedDrops + ) { + try { + ResultSet result = getConnection().execute(statement); + if (result.wasApplied()) { + log.debug("Statement {} was applied", statement); + } else { + throw new ScriptStatementFailedException(statement, lineNumber, scriptPath); + } + } catch (DriverException e) { + throw new ScriptStatementFailedException(statement, lineNumber, scriptPath, e); + } + } + + @Override + protected void closeConnectionQuietly(Session session) { + try { + session.getCluster().close(); + } catch (Exception e) { + log.error("Could not close cassandra connection", e); + } + } +} diff --git a/modules/cassandra/src/main/java/org/testcontainers/containers/wait/CassandraQueryWaitStrategy.java b/modules/cassandra/src/main/java/org/testcontainers/containers/wait/CassandraQueryWaitStrategy.java new file mode 100644 index 00000000000..9694711de6e --- /dev/null +++ b/modules/cassandra/src/main/java/org/testcontainers/containers/wait/CassandraQueryWaitStrategy.java @@ -0,0 +1,50 @@ +package org.testcontainers.containers.wait; + +import org.rnorth.ducttape.TimeoutException; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.delegate.CassandraDatabaseDelegate; +import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; +import org.testcontainers.delegate.DatabaseDelegate; + +import java.util.concurrent.TimeUnit; + +import static org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess; + +/** + * Waits until Cassandra returns its version + * + * @deprecated use {@link org.testcontainers.cassandra.CassandraQueryWaitStrategy} instead. + */ +@Deprecated +public class CassandraQueryWaitStrategy extends AbstractWaitStrategy { + + private static final String SELECT_VERSION_QUERY = "SELECT release_version FROM system.local"; + + private static final String TIMEOUT_ERROR = "Timed out waiting for Cassandra to be accessible for query execution"; + + @Override + protected void waitUntilReady() { + // execute select version query until success or timeout + try { + retryUntilSuccess( + (int) startupTimeout.getSeconds(), + TimeUnit.SECONDS, + () -> { + getRateLimiter() + .doWhenReady(() -> { + try (DatabaseDelegate databaseDelegate = getDatabaseDelegate()) { + databaseDelegate.execute(SELECT_VERSION_QUERY, "", 1, false, false); + } + }); + return true; + } + ); + } catch (TimeoutException e) { + throw new ContainerLaunchException(TIMEOUT_ERROR); + } + } + + private DatabaseDelegate getDatabaseDelegate() { + return new CassandraDatabaseDelegate(waitStrategyTarget); + } +} diff --git a/modules/cassandra/src/main/resources/cqlshrc b/modules/cassandra/src/main/resources/cqlshrc new file mode 100644 index 00000000000..02e001098af --- /dev/null +++ b/modules/cassandra/src/main/resources/cqlshrc @@ -0,0 +1,7 @@ +[ssl] +certfile = ssl/user_cert.pem +usercert = ssl/user_cert.pem +userkey = ssl/user_key.pem + +[connection] +factory = cqlshlib.ssl.ssl_transport_factory \ No newline at end of file diff --git a/modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java b/modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java new file mode 100644 index 00000000000..49243317a40 --- /dev/null +++ b/modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java @@ -0,0 +1,272 @@ +package org.testcontainers.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.CqlSessionBuilder; +import com.datastax.oss.driver.api.core.config.DefaultDriverOption; +import com.datastax.oss.driver.api.core.config.DriverConfigLoader; +import com.datastax.oss.driver.api.core.config.ProgrammaticDriverConfigLoaderBuilder; +import com.datastax.oss.driver.api.core.context.DriverContext; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.core.session.ProgrammaticArguments; +import com.datastax.oss.driver.internal.core.context.DefaultDriverContext; +import com.datastax.oss.driver.internal.core.ssl.DefaultSslEngineFactory; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.utility.DockerImageName; + +import java.net.URL; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.fail; + +class CassandraContainerTest { + + private static final String CASSANDRA_IMAGE = "cassandra:3.11.15"; + + private static final String TEST_CLUSTER_NAME_IN_CONF = "Test Cluster Integration Test"; + + private static final String BASIC_QUERY = "SELECT release_version FROM system.local"; + + @Test + void testSimple() { + try ( // container-definition { + CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE) + // } + ) { + cassandraContainer.start(); + ResultSet resultSet = performQuery(cassandraContainer, BASIC_QUERY); + assertThat(resultSet.wasApplied()).as("Query was applied").isTrue(); + assertThat(resultSet.one().getString(0)).as("Result set has release_version").isNotNull(); + } + } + + @Test + void testSpecificVersion() { + String cassandraVersion = "3.0.15"; + try ( + CassandraContainer cassandraContainer = new CassandraContainer( + DockerImageName.parse("cassandra").withTag(cassandraVersion) + ) + ) { + cassandraContainer.start(); + ResultSet resultSet = performQuery(cassandraContainer, BASIC_QUERY); + assertThat(resultSet.wasApplied()).as("Query was applied").isTrue(); + assertThat(resultSet.one().getString(0)).as("Cassandra has right version").isEqualTo(cassandraVersion); + } + } + + @Test + void testConfigurationOverride() { + try ( + CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE) + .withConfigurationOverride("cassandra-test-configuration-example") + ) { + cassandraContainer.start(); + ResultSet resultSet = performQuery(cassandraContainer, "SELECT cluster_name FROM system.local"); + assertThat(resultSet.wasApplied()).as("Query was applied").isTrue(); + assertThat(resultSet.one().getString(0)) + .as("Cassandra configuration is overridden") + .isEqualTo(TEST_CLUSTER_NAME_IN_CONF); + } + } + + @Test + public void testWithSslClientConfig() { + /* + Commands executed to generate certificates in 'cassandra-ssl-configuration' directory: + keytool -genkey -keyalg RSA -validity 36500 -alias localhost -keystore keystore.p12 -storepass cassandra \ + -keypass cassandra -dname "CN=localhost, OU=Testcontainers, O=Testcontainers, L=None, C=None" + keytool -export -alias localhost -file cassandra.cer -keystore keystore.p12 + keytool -import -v -trustcacerts -alias localhost -file cassandra.cer -keystore truststore.p12 + + Commands executed to generate the client certificate and key in 'client-ssl' directory: + keytool -importkeystore -srckeystore keystore.p12 -destkeystore test_node.p12 -deststoretype PKCS12 \ + -srcstorepass cassandra -deststorepass cassandra + openssl pkcs12 -in test_node.p12 -nokeys -out cassandra.cer.pem -passin pass:cassandra + openssl pkcs12 -in test_node.p12 -nodes -nocerts -out cassandra.key.pem -passin pass:cassandra + + Reference: + https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureSSLCertificates.html + https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/configuration/secureCqlshSSL.html + */ + try ( + // with-ssl-config { + CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE) + .withConfigurationOverride("cassandra-ssl-configuration") + .withSsl("client-ssl/cassandra.cer.pem", "client-ssl/cassandra.key.pem") + // } + ) { + cassandraContainer.start(); + try { + ResultSet resultSet = performQueryWithSslClientConfig( + cassandraContainer, + "SELECT cluster_name FROM system.local" + ); + assertThat(resultSet.wasApplied()).as("Query was applied").isTrue(); + assertThat(resultSet.one().getString(0)) + .as("Cassandra configuration is configured with secured connection") + .isEqualTo(TEST_CLUSTER_NAME_IN_CONF); + } catch (Exception e) { + fail(e); + } + } + } + + @Test + public void testSimpleSslCqlsh() { + try ( + CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE) + .withConfigurationOverride("cassandra-ssl-configuration") + .withSsl("client-ssl/cassandra.cer.pem", "client-ssl/cassandra.key.pem") + ) { + cassandraContainer.start(); + + Container.ExecResult execResult = cassandraContainer.execInContainer( + "cqlsh", + "--ssl", + "-e", + "SELECT * FROM system_schema.keyspaces;" + ); + assertThat(execResult.getStdout()).contains("keyspace_name"); + } catch (Exception e) { + fail(e); + } + } + + @Test + void testEmptyConfigurationOverride() { + try ( + CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE) + .withConfigurationOverride("cassandra-empty-configuration") + ) { + assertThatThrownBy(cassandraContainer::start).isInstanceOf(ContainerLaunchException.class); + } + } + + @Test + void testInitScript() { + try ( + CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE) + .withInitScript("initial.cql") + ) { + cassandraContainer.start(); + testInitScript(cassandraContainer, false); + } + } + + @Test + void testNonexistentInitScript() { + try ( + CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE) + .withInitScript("unknown_script.cql") + ) { + assertThatThrownBy(cassandraContainer::start).isInstanceOf(ContainerLaunchException.class); + } + } + + @Test + void testInitScriptWithRequiredAuthentication() { + try ( + // init-with-auth { + CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE) + .withConfigurationOverride("cassandra-auth-required-configuration") + .withInitScript("initial.cql") + // } + ) { + cassandraContainer.start(); + testInitScript(cassandraContainer, true); + } + } + + @Test + void testInitScriptWithError() { + try ( + CassandraContainer cassandraContainer = new CassandraContainer(CASSANDRA_IMAGE) + .withInitScript("initial-with-error.cql") + ) { + assertThatThrownBy(cassandraContainer::start).isInstanceOf(ContainerLaunchException.class); + } + } + + @Test + void testInitScriptWithLegacyCassandra() { + try ( + CassandraContainer cassandraContainer = new CassandraContainer("cassandra:2.2.11") + .withInitScript("initial.cql") + ) { + cassandraContainer.start(); + testInitScript(cassandraContainer, false); + } + } + + private void testInitScript(CassandraContainer cassandraContainer, boolean withCredentials) { + String query = "SELECT * FROM keySpaceTest.catalog_category"; + ResultSet resultSet; + + if (withCredentials) { + resultSet = performQueryWithAuth(cassandraContainer, query); + } else { + resultSet = performQuery(cassandraContainer, query); + } + + assertThat(resultSet.wasApplied()).as("Query was applied").isTrue(); + Row row = resultSet.one(); + assertThat(row.getLong(0)).as("Inserted row is in expected state").isEqualTo(1); + assertThat(row.getString(1)).as("Inserted row is in expected state").isEqualTo("test_category"); + } + + private ResultSet performQuery(CassandraContainer cassandraContainer, String cql) { + // cql-session { + final CqlSession cqlSession = CqlSession + .builder() + .addContactPoint(cassandraContainer.getContactPoint()) + .withLocalDatacenter(cassandraContainer.getLocalDatacenter()) + .build(); + // } + return performQuery(cqlSession, cql); + } + + private ResultSet performQueryWithAuth(CassandraContainer cassandraContainer, String cql) { + final CqlSession cqlSession = CqlSession + .builder() + .addContactPoint(cassandraContainer.getContactPoint()) + .withLocalDatacenter(cassandraContainer.getLocalDatacenter()) + .withAuthCredentials(cassandraContainer.getUsername(), cassandraContainer.getPassword()) + .build(); + return performQuery(cqlSession, cql); + } + + private ResultSet performQueryWithSslClientConfig(CassandraContainer cassandraContainer, String cql) { + final ProgrammaticDriverConfigLoaderBuilder driverConfigLoaderBuilder = DriverConfigLoader.programmaticBuilder(); + driverConfigLoaderBuilder.withBoolean(DefaultDriverOption.SSL_HOSTNAME_VALIDATION, false); + final URL trustStoreUrl = + this.getClass().getClassLoader().getResource("cassandra-ssl-configuration/truststore.p12"); + driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_TRUSTSTORE_PATH, trustStoreUrl.getFile()); + driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_TRUSTSTORE_PASSWORD, "cassandra"); + final URL keyStoreUrl = + this.getClass().getClassLoader().getResource("cassandra-ssl-configuration/keystore.p12"); + driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_KEYSTORE_PATH, keyStoreUrl.getFile()); + driverConfigLoaderBuilder.withString(DefaultDriverOption.SSL_KEYSTORE_PASSWORD, "cassandra"); + final DriverContext driverContext = new DefaultDriverContext( + driverConfigLoaderBuilder.build(), + ProgrammaticArguments.builder().build() + ); + + final CqlSessionBuilder sessionBuilder = CqlSession.builder(); + final CqlSession cqlSession = sessionBuilder + .addContactPoint(cassandraContainer.getContactPoint()) + .withLocalDatacenter(cassandraContainer.getLocalDatacenter()) + .withSslEngineFactory(new DefaultSslEngineFactory(driverContext)) + .build(); + return performQuery(cqlSession, cql); + } + + private ResultSet performQuery(CqlSession session, String cql) { + final ResultSet rs = session.execute(cql); + session.close(); + return rs; + } +} diff --git a/modules/cassandra/src/test/java/org/testcontainers/cassandra/CompatibleCassandraImageTest.java b/modules/cassandra/src/test/java/org/testcontainers/cassandra/CompatibleCassandraImageTest.java new file mode 100644 index 00000000000..15d8e209911 --- /dev/null +++ b/modules/cassandra/src/test/java/org/testcontainers/cassandra/CompatibleCassandraImageTest.java @@ -0,0 +1,44 @@ +package org.testcontainers.cassandra; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.metadata.schema.KeyspaceMetadata; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class CompatibleCassandraImageTest { + + public static String[] params() { + return new String[] { "cassandra:3.11.2", "cassandra:4.1.1", "cassandra:5" }; + } + + @ParameterizedTest + @MethodSource("params") + void testCassandraGetContactPoint(String imageName) { + try (CassandraContainer cassandra = new CassandraContainer(imageName)) { + cassandra.start(); + assertCassandraFunctionality(cassandra); + } + } + + private void assertCassandraFunctionality(CassandraContainer cassandra) { + try ( + CqlSession session = CqlSession + .builder() + .addContactPoint(cassandra.getContactPoint()) + .withLocalDatacenter(cassandra.getLocalDatacenter()) + .build() + ) { + session.execute( + "CREATE KEYSPACE IF NOT EXISTS test WITH replication = \n" + + "{'class':'SimpleStrategy','replication_factor':'1'};" + ); + + KeyspaceMetadata keyspace = session.getMetadata().getKeyspaces().get(CqlIdentifier.fromCql("test")); + + assertThat(keyspace).as("test keyspace created").isNotNull(); + } + } +} diff --git a/modules/cassandra/src/test/java/org/testcontainers/containers/CassandraContainerTest.java b/modules/cassandra/src/test/java/org/testcontainers/containers/CassandraContainerTest.java new file mode 100644 index 00000000000..2d534bccc81 --- /dev/null +++ b/modules/cassandra/src/test/java/org/testcontainers/containers/CassandraContainerTest.java @@ -0,0 +1,145 @@ +package org.testcontainers.containers; + +import com.datastax.driver.core.Cluster; +import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.Row; +import com.datastax.driver.core.Session; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.wait.CassandraQueryWaitStrategy; +import org.testcontainers.utility.DockerImageName; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Slf4j +class CassandraContainerTest { + + private static final DockerImageName CASSANDRA_IMAGE = DockerImageName.parse("cassandra:3.11.2"); + + private static final String TEST_CLUSTER_NAME_IN_CONF = "Test Cluster Integration Test"; + + private static final String BASIC_QUERY = "SELECT release_version FROM system.local"; + + @Test + void testSimple() { + try (CassandraContainer cassandraContainer = new CassandraContainer<>(CASSANDRA_IMAGE)) { + cassandraContainer.start(); + ResultSet resultSet = performQuery(cassandraContainer, BASIC_QUERY); + assertThat(resultSet.wasApplied()).as("Query was applied").isTrue(); + assertThat(resultSet.one().getString(0)).as("Result set has release_version").isNotNull(); + } + } + + @Test + void testSpecificVersion() { + String cassandraVersion = "3.0.15"; + try ( + CassandraContainer cassandraContainer = new CassandraContainer<>( + CASSANDRA_IMAGE.withTag(cassandraVersion) + ) + ) { + cassandraContainer.start(); + ResultSet resultSet = performQuery(cassandraContainer, BASIC_QUERY); + assertThat(resultSet.wasApplied()).as("Query was applied").isTrue(); + assertThat(resultSet.one().getString(0)).as("Cassandra has right version").isEqualTo(cassandraVersion); + } + } + + @Test + void testConfigurationOverride() { + try ( + CassandraContainer cassandraContainer = new CassandraContainer<>(CASSANDRA_IMAGE) + .withConfigurationOverride("cassandra-test-configuration-example") + ) { + cassandraContainer.start(); + ResultSet resultSet = performQuery(cassandraContainer, "SELECT cluster_name FROM system.local"); + assertThat(resultSet.wasApplied()).as("Query was applied").isTrue(); + assertThat(resultSet.one().getString(0)) + .as("Cassandra configuration is overridden") + .isEqualTo(TEST_CLUSTER_NAME_IN_CONF); + } + } + + @Test + void testEmptyConfigurationOverride() { + try ( + CassandraContainer cassandraContainer = new CassandraContainer<>(CASSANDRA_IMAGE) + .withConfigurationOverride("cassandra-empty-configuration") + ) { + assertThatThrownBy(cassandraContainer::start).isInstanceOf(ContainerLaunchException.class); + } + } + + @Test + void testInitScript() { + try ( + CassandraContainer cassandraContainer = new CassandraContainer<>(CASSANDRA_IMAGE) + .withInitScript("initial.cql") + ) { + cassandraContainer.start(); + testInitScript(cassandraContainer); + } + } + + @Test + void testInitScriptWithLegacyCassandra() { + try ( + CassandraContainer cassandraContainer = new CassandraContainer<>( + DockerImageName.parse("cassandra:2.2.11") + ) + .withInitScript("initial.cql") + ) { + cassandraContainer.start(); + testInitScript(cassandraContainer); + } + } + + @SuppressWarnings("deprecation") // Using deprecated constructor for verification of backwards compatibility + @Test + void testCassandraQueryWaitStrategy() { + try ( + CassandraContainer cassandraContainer = new CassandraContainer<>() + .waitingFor(new CassandraQueryWaitStrategy()) + ) { + cassandraContainer.start(); + ResultSet resultSet = performQuery(cassandraContainer, BASIC_QUERY); + assertThat(resultSet.wasApplied()).as("Query was applied").isTrue(); + } + } + + @SuppressWarnings("deprecation") // Using deprecated constructor for verification of backwards compatibility + @Test + void testCassandraGetCluster() { + try (CassandraContainer cassandraContainer = new CassandraContainer<>()) { + cassandraContainer.start(); + ResultSet resultSet = performQuery(cassandraContainer.getCluster(), BASIC_QUERY); + assertThat(resultSet.wasApplied()).as("Query was applied").isTrue(); + assertThat(resultSet.one().getString(0)).as("Result set has release_version").isNotNull(); + } + } + + private void testInitScript(CassandraContainer cassandraContainer) { + ResultSet resultSet = performQuery(cassandraContainer, "SELECT * FROM keySpaceTest.catalog_category"); + assertThat(resultSet.wasApplied()).as("Query was applied").isTrue(); + Row row = resultSet.one(); + assertThat(row.getLong(0)).as("Inserted row is in expected state").isEqualTo(1); + assertThat(row.getString(1)).as("Inserted row is in expected state").isEqualTo("test_category"); + } + + private ResultSet performQuery(CassandraContainer cassandraContainer, String cql) { + Cluster explicitCluster = Cluster + .builder() + .addContactPoint(cassandraContainer.getHost()) + .withPort(cassandraContainer.getMappedPort(CassandraContainer.CQL_PORT)) + .build(); + return performQuery(explicitCluster, cql); + } + + private ResultSet performQuery(Cluster cluster, String cql) { + try (Cluster closeableCluster = cluster) { + Session session = closeableCluster.newSession(); + return session.execute(cql); + } + } +} diff --git a/modules/cassandra/src/test/java/org/testcontainers/containers/CompatibleCassandraImageTest.java b/modules/cassandra/src/test/java/org/testcontainers/containers/CompatibleCassandraImageTest.java new file mode 100644 index 00000000000..aec27566beb --- /dev/null +++ b/modules/cassandra/src/test/java/org/testcontainers/containers/CompatibleCassandraImageTest.java @@ -0,0 +1,44 @@ +package org.testcontainers.containers; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.metadata.schema.KeyspaceMetadata; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CompatibleCassandraImageTest { + + public static String[] params() { + return new String[] { "cassandra:3.11.2", "cassandra:4.1.1" }; + } + + @ParameterizedTest + @MethodSource("params") + void testCassandraGetContactPoint(String imageName) { + try (CassandraContainer cassandra = new CassandraContainer<>(imageName)) { + cassandra.start(); + assertCassandraFunctionality(cassandra); + } + } + + private void assertCassandraFunctionality(CassandraContainer cassandra) { + try ( + CqlSession session = CqlSession + .builder() + .addContactPoint(cassandra.getContactPoint()) + .withLocalDatacenter(cassandra.getLocalDatacenter()) + .build() + ) { + session.execute( + "CREATE KEYSPACE IF NOT EXISTS test WITH replication = \n" + + "{'class':'SimpleStrategy','replication_factor':'1'};" + ); + + KeyspaceMetadata keyspace = session.getMetadata().getKeyspaces().get(CqlIdentifier.fromCql("test")); + + assertThat(keyspace).as("test keyspace created").isNotNull(); + } + } +} diff --git a/modules/cassandra/src/test/resources/cassandra-auth-required-configuration/cassandra.yaml b/modules/cassandra/src/test/resources/cassandra-auth-required-configuration/cassandra.yaml new file mode 100644 index 00000000000..7425881b8fe --- /dev/null +++ b/modules/cassandra/src/test/resources/cassandra-auth-required-configuration/cassandra.yaml @@ -0,0 +1,1233 @@ +# Cassandra storage config YAML + +# NOTE: +# See http://wiki.apache.org/cassandra/StorageConfiguration for +# full explanations of configuration directives +# /NOTE + +# The name of the cluster. This is mainly used to prevent machines in +# one logical cluster from joining another. +cluster_name: 'Test Cluster Integration Test' + +# This defines the number of tokens randomly assigned to this node on the ring +# The more tokens, relative to other nodes, the larger the proportion of data +# that this node will store. You probably want all nodes to have the same number +# of tokens assuming they have equal hardware capability. +# +# If you leave this unspecified, Cassandra will use the default of 1 token for legacy compatibility, +# and will use the initial_token as described below. +# +# Specifying initial_token will override this setting on the node's initial start, +# on subsequent starts, this setting will apply even if initial token is set. +# +# If you already have a cluster with 1 token per node, and wish to migrate to +# multiple tokens per node, see http://wiki.apache.org/cassandra/Operations +num_tokens: 256 + +# Triggers automatic allocation of num_tokens tokens for this node. The allocation +# algorithm attempts to choose tokens in a way that optimizes replicated load over +# the nodes in the datacenter for the replication strategy used by the specified +# keyspace. +# +# The load assigned to each node will be close to proportional to its number of +# vnodes. +# +# Only supported with the Murmur3Partitioner. +# allocate_tokens_for_keyspace: KEYSPACE + +# initial_token allows you to specify tokens manually. While you can use it with +# vnodes (num_tokens > 1, above) -- in which case you should provide a +# comma-separated list -- it's primarily used when adding nodes to legacy clusters +# that do not have vnodes enabled. +# initial_token: + +# See http://wiki.apache.org/cassandra/HintedHandoff +# May either be "true" or "false" to enable globally +hinted_handoff_enabled: true + +# When hinted_handoff_enabled is true, a black list of data centers that will not +# perform hinted handoff +# hinted_handoff_disabled_datacenters: +# - DC1 +# - DC2 + +# this defines the maximum amount of time a dead host will have hints +# generated. After it has been dead this long, new hints for it will not be +# created until it has been seen alive and gone down again. +max_hint_window_in_ms: 10800000 # 3 hours + +# Maximum throttle in KBs per second, per delivery thread. This will be +# reduced proportionally to the number of nodes in the cluster. (If there +# are two nodes in the cluster, each delivery thread will use the maximum +# rate; if there are three, each will throttle to half of the maximum, +# since we expect two nodes to be delivering hints simultaneously.) +hinted_handoff_throttle_in_kb: 1024 + +# Number of threads with which to deliver hints; +# Consider increasing this number when you have multi-dc deployments, since +# cross-dc handoff tends to be slower +max_hints_delivery_threads: 2 + +# Directory where Cassandra should store hints. +# If not set, the default directory is $CASSANDRA_HOME/data/hints. +# hints_directory: /var/lib/cassandra/hints + +# How often hints should be flushed from the internal buffers to disk. +# Will *not* trigger fsync. +hints_flush_period_in_ms: 10000 + +# Maximum size for a single hints file, in megabytes. +max_hints_file_size_in_mb: 128 + +# Compression to apply to the hint files. If omitted, hints files +# will be written uncompressed. LZ4, Snappy, and Deflate compressors +# are supported. +#hints_compression: +# - class_name: LZ4Compressor +# parameters: +# - + +# Maximum throttle in KBs per second, total. This will be +# reduced proportionally to the number of nodes in the cluster. +batchlog_replay_throttle_in_kb: 1024 + +# Authentication backend, implementing IAuthenticator; used to identify users +# Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllAuthenticator, +# PasswordAuthenticator}. +# +# - AllowAllAuthenticator performs no checks - set it to disable authentication. +# - PasswordAuthenticator relies on username/password pairs to authenticate +# users. It keeps usernames and hashed passwords in system_auth.roles table. +# Please increase system_auth keyspace replication factor if you use this authenticator. +# If using PasswordAuthenticator, CassandraRoleManager must also be used (see below) +authenticator: PasswordAuthenticator + +# Authorization backend, implementing IAuthorizer; used to limit access/provide permissions +# Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllAuthorizer, +# CassandraAuthorizer}. +# +# - AllowAllAuthorizer allows any action to any user - set it to disable authorization. +# - CassandraAuthorizer stores permissions in system_auth.role_permissions table. Please +# increase system_auth keyspace replication factor if you use this authorizer. +authorizer: AllowAllAuthorizer + +# Part of the Authentication & Authorization backend, implementing IRoleManager; used +# to maintain grants and memberships between roles. +# Out of the box, Cassandra provides org.apache.cassandra.auth.CassandraRoleManager, +# which stores role information in the system_auth keyspace. Most functions of the +# IRoleManager require an authenticated login, so unless the configured IAuthenticator +# actually implements authentication, most of this functionality will be unavailable. +# +# - CassandraRoleManager stores role data in the system_auth keyspace. Please +# increase system_auth keyspace replication factor if you use this role manager. +role_manager: CassandraRoleManager + +# Validity period for roles cache (fetching granted roles can be an expensive +# operation depending on the role manager, CassandraRoleManager is one example) +# Granted roles are cached for authenticated sessions in AuthenticatedUser and +# after the period specified here, become eligible for (async) reload. +# Defaults to 2000, set to 0 to disable caching entirely. +# Will be disabled automatically for AllowAllAuthenticator. +roles_validity_in_ms: 2000 + +# Refresh interval for roles cache (if enabled). +# After this interval, cache entries become eligible for refresh. Upon next +# access, an async reload is scheduled and the old value returned until it +# completes. If roles_validity_in_ms is non-zero, then this must be +# also. +# Defaults to the same value as roles_validity_in_ms. +# roles_update_interval_in_ms: 2000 + +# Validity period for permissions cache (fetching permissions can be an +# expensive operation depending on the authorizer, CassandraAuthorizer is +# one example). Defaults to 2000, set to 0 to disable. +# Will be disabled automatically for AllowAllAuthorizer. +permissions_validity_in_ms: 2000 + +# Refresh interval for permissions cache (if enabled). +# After this interval, cache entries become eligible for refresh. Upon next +# access, an async reload is scheduled and the old value returned until it +# completes. If permissions_validity_in_ms is non-zero, then this must be +# also. +# Defaults to the same value as permissions_validity_in_ms. +# permissions_update_interval_in_ms: 2000 + +# Validity period for credentials cache. This cache is tightly coupled to +# the provided PasswordAuthenticator implementation of IAuthenticator. If +# another IAuthenticator implementation is configured, this cache will not +# be automatically used and so the following settings will have no effect. +# Please note, credentials are cached in their encrypted form, so while +# activating this cache may reduce the number of queries made to the +# underlying table, it may not bring a significant reduction in the +# latency of individual authentication attempts. +# Defaults to 2000, set to 0 to disable credentials caching. +credentials_validity_in_ms: 2000 + +# Refresh interval for credentials cache (if enabled). +# After this interval, cache entries become eligible for refresh. Upon next +# access, an async reload is scheduled and the old value returned until it +# completes. If credentials_validity_in_ms is non-zero, then this must be +# also. +# Defaults to the same value as credentials_validity_in_ms. +# credentials_update_interval_in_ms: 2000 + +# The partitioner is responsible for distributing groups of rows (by +# partition key) across nodes in the cluster. You should leave this +# alone for new clusters. The partitioner can NOT be changed without +# reloading all data, so when upgrading you should set this to the +# same partitioner you were already using. +# +# Besides Murmur3Partitioner, partitioners included for backwards +# compatibility include RandomPartitioner, ByteOrderedPartitioner, and +# OrderPreservingPartitioner. +# +partitioner: org.apache.cassandra.dht.Murmur3Partitioner + +# Directories where Cassandra should store data on disk. Cassandra +# will spread data evenly across them, subject to the granularity of +# the configured compaction strategy. +# If not set, the default directory is $CASSANDRA_HOME/data/data. +data_file_directories: + - /var/lib/cassandra/data + +# commit log. when running on magnetic HDD, this should be a +# separate spindle than the data directories. +# If not set, the default directory is $CASSANDRA_HOME/data/commitlog. +commitlog_directory: /var/lib/cassandra/commitlog + +# Enable / disable CDC functionality on a per-node basis. This modifies the logic used +# for write path allocation rejection (standard: never reject. cdc: reject Mutation +# containing a CDC-enabled table if at space limit in cdc_raw_directory). +cdc_enabled: false + +# CommitLogSegments are moved to this directory on flush if cdc_enabled: true and the +# segment contains mutations for a CDC-enabled table. This should be placed on a +# separate spindle than the data directories. If not set, the default directory is +# $CASSANDRA_HOME/data/cdc_raw. +# cdc_raw_directory: /var/lib/cassandra/cdc_raw + +# Policy for data disk failures: +# +# die +# shut down gossip and client transports and kill the JVM for any fs errors or +# single-sstable errors, so the node can be replaced. +# +# stop_paranoid +# shut down gossip and client transports even for single-sstable errors, +# kill the JVM for errors during startup. +# +# stop +# shut down gossip and client transports, leaving the node effectively dead, but +# can still be inspected via JMX, kill the JVM for errors during startup. +# +# best_effort +# stop using the failed disk and respond to requests based on +# remaining available sstables. This means you WILL see obsolete +# data at CL.ONE! +# +# ignore +# ignore fatal errors and let requests fail, as in pre-1.2 Cassandra +disk_failure_policy: stop + +# Policy for commit disk failures: +# +# die +# shut down gossip and Thrift and kill the JVM, so the node can be replaced. +# +# stop +# shut down gossip and Thrift, leaving the node effectively dead, but +# can still be inspected via JMX. +# +# stop_commit +# shutdown the commit log, letting writes collect but +# continuing to service reads, as in pre-2.0.5 Cassandra +# +# ignore +# ignore fatal errors and let the batches fail +commit_failure_policy: stop + +# Maximum size of the native protocol prepared statement cache +# +# Valid values are either "auto" (omitting the value) or a value greater 0. +# +# Note that specifying a too large value will result in long running GCs and possibly +# out-of-memory errors. Keep the value at a small fraction of the heap. +# +# If you constantly see "prepared statements discarded in the last minute because +# cache limit reached" messages, the first step is to investigate the root cause +# of these messages and check whether prepared statements are used correctly - +# i.e. use bind markers for variable parts. +# +# Do only change the default value, if you really have more prepared statements than +# fit in the cache. In most cases it is not necessary to change this value. +# Constantly re-preparing statements is a performance penalty. +# +# Default value ("auto") is 1/256th of the heap or 10MB, whichever is greater +prepared_statements_cache_size_mb: + +# Maximum size of the Thrift prepared statement cache +# +# If you do not use Thrift at all, it is safe to leave this value at "auto". +# +# See description of 'prepared_statements_cache_size_mb' above for more information. +# +# Default value ("auto") is 1/256th of the heap or 10MB, whichever is greater +thrift_prepared_statements_cache_size_mb: + +# Maximum size of the key cache in memory. +# +# Each key cache hit saves 1 seek and each row cache hit saves 2 seeks at the +# minimum, sometimes more. The key cache is fairly tiny for the amount of +# time it saves, so it's worthwhile to use it at large numbers. +# The row cache saves even more time, but must contain the entire row, +# so it is extremely space-intensive. It's best to only use the +# row cache if you have hot rows or static rows. +# +# NOTE: if you reduce the size, you may not get you hottest keys loaded on startup. +# +# Default value is empty to make it "auto" (min(5% of Heap (in MB), 100MB)). Set to 0 to disable key cache. +key_cache_size_in_mb: + +# Duration in seconds after which Cassandra should +# save the key cache. Caches are saved to saved_caches_directory as +# specified in this configuration file. +# +# Saved caches greatly improve cold-start speeds, and is relatively cheap in +# terms of I/O for the key cache. Row cache saving is much more expensive and +# has limited use. +# +# Default is 14400 or 4 hours. +key_cache_save_period: 14400 + +# Number of keys from the key cache to save +# Disabled by default, meaning all keys are going to be saved +# key_cache_keys_to_save: 100 + +# Row cache implementation class name. Available implementations: +# +# org.apache.cassandra.cache.OHCProvider +# Fully off-heap row cache implementation (default). +# +# org.apache.cassandra.cache.SerializingCacheProvider +# This is the row cache implementation available +# in previous releases of Cassandra. +# row_cache_class_name: org.apache.cassandra.cache.OHCProvider + +# Maximum size of the row cache in memory. +# Please note that OHC cache implementation requires some additional off-heap memory to manage +# the map structures and some in-flight memory during operations before/after cache entries can be +# accounted against the cache capacity. This overhead is usually small compared to the whole capacity. +# Do not specify more memory that the system can afford in the worst usual situation and leave some +# headroom for OS block level cache. Do never allow your system to swap. +# +# Default value is 0, to disable row caching. +row_cache_size_in_mb: 0 + +# Duration in seconds after which Cassandra should save the row cache. +# Caches are saved to saved_caches_directory as specified in this configuration file. +# +# Saved caches greatly improve cold-start speeds, and is relatively cheap in +# terms of I/O for the key cache. Row cache saving is much more expensive and +# has limited use. +# +# Default is 0 to disable saving the row cache. +row_cache_save_period: 0 + +# Number of keys from the row cache to save. +# Specify 0 (which is the default), meaning all keys are going to be saved +# row_cache_keys_to_save: 100 + +# Maximum size of the counter cache in memory. +# +# Counter cache helps to reduce counter locks' contention for hot counter cells. +# In case of RF = 1 a counter cache hit will cause Cassandra to skip the read before +# write entirely. With RF > 1 a counter cache hit will still help to reduce the duration +# of the lock hold, helping with hot counter cell updates, but will not allow skipping +# the read entirely. Only the local (clock, count) tuple of a counter cell is kept +# in memory, not the whole counter, so it's relatively cheap. +# +# NOTE: if you reduce the size, you may not get you hottest keys loaded on startup. +# +# Default value is empty to make it "auto" (min(2.5% of Heap (in MB), 50MB)). Set to 0 to disable counter cache. +# NOTE: if you perform counter deletes and rely on low gcgs, you should disable the counter cache. +counter_cache_size_in_mb: + +# Duration in seconds after which Cassandra should +# save the counter cache (keys only). Caches are saved to saved_caches_directory as +# specified in this configuration file. +# +# Default is 7200 or 2 hours. +counter_cache_save_period: 7200 + +# Number of keys from the counter cache to save +# Disabled by default, meaning all keys are going to be saved +# counter_cache_keys_to_save: 100 + +# saved caches +# If not set, the default directory is $CASSANDRA_HOME/data/saved_caches. +saved_caches_directory: /var/lib/cassandra/saved_caches + +# commitlog_sync may be either "periodic" or "batch." +# +# When in batch mode, Cassandra won't ack writes until the commit log +# has been fsynced to disk. It will wait +# commitlog_sync_batch_window_in_ms milliseconds between fsyncs. +# This window should be kept short because the writer threads will +# be unable to do extra work while waiting. (You may need to increase +# concurrent_writes for the same reason.) +# +# commitlog_sync: batch +# commitlog_sync_batch_window_in_ms: 2 +# +# the other option is "periodic" where writes may be acked immediately +# and the CommitLog is simply synced every commitlog_sync_period_in_ms +# milliseconds. +commitlog_sync: periodic +commitlog_sync_period_in_ms: 10000 + +# The size of the individual commitlog file segments. A commitlog +# segment may be archived, deleted, or recycled once all the data +# in it (potentially from each columnfamily in the system) has been +# flushed to sstables. +# +# The default size is 32, which is almost always fine, but if you are +# archiving commitlog segments (see commitlog_archiving.properties), +# then you probably want a finer granularity of archiving; 8 or 16 MB +# is reasonable. +# Max mutation size is also configurable via max_mutation_size_in_kb setting in +# cassandra.yaml. The default is half the size commitlog_segment_size_in_mb * 1024. +# This should be positive and less than 2048. +# +# NOTE: If max_mutation_size_in_kb is set explicitly then commitlog_segment_size_in_mb must +# be set to at least twice the size of max_mutation_size_in_kb / 1024 +# +commitlog_segment_size_in_mb: 32 + +# Compression to apply to the commit log. If omitted, the commit log +# will be written uncompressed. LZ4, Snappy, and Deflate compressors +# are supported. +# commitlog_compression: +# - class_name: LZ4Compressor +# parameters: +# - + +# any class that implements the SeedProvider interface and has a +# constructor that takes a Map of parameters will do. +seed_provider: + # Addresses of hosts that are deemed contact points. + # Cassandra nodes use this list of hosts to find each other and learn + # the topology of the ring. You must change this if you are running + # multiple nodes! + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + # seeds is actually a comma-delimited list of addresses. + # Ex: ",," + - seeds: "172.17.0.2" + +# For workloads with more data than can fit in memory, Cassandra's +# bottleneck will be reads that need to fetch data from +# disk. "concurrent_reads" should be set to (16 * number_of_drives) in +# order to allow the operations to enqueue low enough in the stack +# that the OS and drives can reorder them. Same applies to +# "concurrent_counter_writes", since counter writes read the current +# values before incrementing and writing them back. +# +# On the other hand, since writes are almost never IO bound, the ideal +# number of "concurrent_writes" is dependent on the number of cores in +# your system; (8 * number_of_cores) is a good rule of thumb. +concurrent_reads: 32 +concurrent_writes: 32 +concurrent_counter_writes: 32 + +# For materialized view writes, as there is a read involved, so this should +# be limited by the less of concurrent reads or concurrent writes. +concurrent_materialized_view_writes: 32 + +# Maximum memory to use for sstable chunk cache and buffer pooling. +# 32MB of this are reserved for pooling buffers, the rest is used as a +# cache that holds uncompressed sstable chunks. +# Defaults to the smaller of 1/4 of heap or 512MB. This pool is allocated off-heap, +# so is in addition to the memory allocated for heap. The cache also has on-heap +# overhead which is roughly 128 bytes per chunk (i.e. 0.2% of the reserved size +# if the default 64k chunk size is used). +# Memory is only allocated when needed. +# file_cache_size_in_mb: 512 + +# Flag indicating whether to allocate on or off heap when the sstable buffer +# pool is exhausted, that is when it has exceeded the maximum memory +# file_cache_size_in_mb, beyond which it will not cache buffers but allocate on request. + +# buffer_pool_use_heap_if_exhausted: true + +# The strategy for optimizing disk read +# Possible values are: +# ssd (for solid state disks, the default) +# spinning (for spinning disks) +# disk_optimization_strategy: ssd + +# Total permitted memory to use for memtables. Cassandra will stop +# accepting writes when the limit is exceeded until a flush completes, +# and will trigger a flush based on memtable_cleanup_threshold +# If omitted, Cassandra will set both to 1/4 the size of the heap. +# memtable_heap_space_in_mb: 2048 +# memtable_offheap_space_in_mb: 2048 + +# memtable_cleanup_threshold is deprecated. The default calculation +# is the only reasonable choice. See the comments on memtable_flush_writers +# for more information. +# +# Ratio of occupied non-flushing memtable size to total permitted size +# that will trigger a flush of the largest memtable. Larger mct will +# mean larger flushes and hence less compaction, but also less concurrent +# flush activity which can make it difficult to keep your disks fed +# under heavy write load. +# +# memtable_cleanup_threshold defaults to 1 / (memtable_flush_writers + 1) +# memtable_cleanup_threshold: 0.11 + +# Specify the way Cassandra allocates and manages memtable memory. +# Options are: +# +# heap_buffers +# on heap nio buffers +# +# offheap_buffers +# off heap (direct) nio buffers +# +# offheap_objects +# off heap objects +memtable_allocation_type: heap_buffers + +# Total space to use for commit logs on disk. +# +# If space gets above this value, Cassandra will flush every dirty CF +# in the oldest segment and remove it. So a small total commitlog space +# will tend to cause more flush activity on less-active columnfamilies. +# +# The default value is the smaller of 8192, and 1/4 of the total space +# of the commitlog volume. +# +# commitlog_total_space_in_mb: 8192 + +# This sets the number of memtable flush writer threads per disk +# as well as the total number of memtables that can be flushed concurrently. +# These are generally a combination of compute and IO bound. +# +# Memtable flushing is more CPU efficient than memtable ingest and a single thread +# can keep up with the ingest rate of a whole server on a single fast disk +# until it temporarily becomes IO bound under contention typically with compaction. +# At that point you need multiple flush threads. At some point in the future +# it may become CPU bound all the time. +# +# You can tell if flushing is falling behind using the MemtablePool.BlockedOnAllocation +# metric which should be 0, but will be non-zero if threads are blocked waiting on flushing +# to free memory. +# +# memtable_flush_writers defaults to two for a single data directory. +# This means that two memtables can be flushed concurrently to the single data directory. +# If you have multiple data directories the default is one memtable flushing at a time +# but the flush will use a thread per data directory so you will get two or more writers. +# +# Two is generally enough to flush on a fast disk [array] mounted as a single data directory. +# Adding more flush writers will result in smaller more frequent flushes that introduce more +# compaction overhead. +# +# There is a direct tradeoff between number of memtables that can be flushed concurrently +# and flush size and frequency. More is not better you just need enough flush writers +# to never stall waiting for flushing to free memory. +# +#memtable_flush_writers: 2 + +# Total space to use for change-data-capture logs on disk. +# +# If space gets above this value, Cassandra will throw WriteTimeoutException +# on Mutations including tables with CDC enabled. A CDCCompactor is responsible +# for parsing the raw CDC logs and deleting them when parsing is completed. +# +# The default value is the min of 4096 mb and 1/8th of the total space +# of the drive where cdc_raw_directory resides. +# cdc_total_space_in_mb: 4096 + +# When we hit our cdc_raw limit and the CDCCompactor is either running behind +# or experiencing backpressure, we check at the following interval to see if any +# new space for cdc-tracked tables has been made available. Default to 250ms +# cdc_free_space_check_interval_ms: 250 + +# A fixed memory pool size in MB for SSTable index summaries. If left +# empty, this will default to 5% of the heap size. If the memory usage of +# all index summaries exceeds this limit, SSTables with low read rates will +# shrink their index summaries in order to meet this limit. However, this +# is a best-effort process. In extreme conditions Cassandra may need to use +# more than this amount of memory. +index_summary_capacity_in_mb: + +# How frequently index summaries should be resampled. This is done +# periodically to redistribute memory from the fixed-size pool to sstables +# proportional their recent read rates. Setting to -1 will disable this +# process, leaving existing index summaries at their current sampling level. +index_summary_resize_interval_in_minutes: 60 + +# Whether to, when doing sequential writing, fsync() at intervals in +# order to force the operating system to flush the dirty +# buffers. Enable this to avoid sudden dirty buffer flushing from +# impacting read latencies. Almost always a good idea on SSDs; not +# necessarily on platters. +trickle_fsync: false +trickle_fsync_interval_in_kb: 10240 + +# TCP port, for commands and data +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +storage_port: 7000 + +# SSL port, for encrypted communication. Unused unless enabled in +# encryption_options +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +ssl_storage_port: 7001 + +# Address or interface to bind to and tell other Cassandra nodes to connect to. +# You _must_ change this if you want multiple nodes to be able to communicate! +# +# Set listen_address OR listen_interface, not both. +# +# Leaving it blank leaves it up to InetAddress.getLocalHost(). This +# will always do the Right Thing _if_ the node is properly configured +# (hostname, name resolution, etc), and the Right Thing is to use the +# address associated with the hostname (it might not be). +# +# Setting listen_address to 0.0.0.0 is always wrong. +# +listen_address: 172.17.0.2 + +# Set listen_address OR listen_interface, not both. Interfaces must correspond +# to a single address, IP aliasing is not supported. +# listen_interface: eth0 + +# If you choose to specify the interface by name and the interface has an ipv4 and an ipv6 address +# you can specify which should be chosen using listen_interface_prefer_ipv6. If false the first ipv4 +# address will be used. If true the first ipv6 address will be used. Defaults to false preferring +# ipv4. If there is only one address it will be selected regardless of ipv4/ipv6. +# listen_interface_prefer_ipv6: false + +# Address to broadcast to other Cassandra nodes +# Leaving this blank will set it to the same value as listen_address +broadcast_address: 172.17.0.2 + +# When using multiple physical network interfaces, set this +# to true to listen on broadcast_address in addition to +# the listen_address, allowing nodes to communicate in both +# interfaces. +# Ignore this property if the network configuration automatically +# routes between the public and private networks such as EC2. +# listen_on_broadcast_address: false + +# Internode authentication backend, implementing IInternodeAuthenticator; +# used to allow/disallow connections from peer nodes. +# internode_authenticator: org.apache.cassandra.auth.AllowAllInternodeAuthenticator + +# Whether to start the native transport server. +# Please note that the address on which the native transport is bound is the +# same as the rpc_address. The port however is different and specified below. +start_native_transport: true +# port for the CQL native transport to listen for clients on +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +native_transport_port: 9042 +# Enabling native transport encryption in client_encryption_options allows you to either use +# encryption for the standard port or to use a dedicated, additional port along with the unencrypted +# standard native_transport_port. +# Enabling client encryption and keeping native_transport_port_ssl disabled will use encryption +# for native_transport_port. Setting native_transport_port_ssl to a different value +# from native_transport_port will use encryption for native_transport_port_ssl while +# keeping native_transport_port unencrypted. +# native_transport_port_ssl: 9142 +# The maximum threads for handling requests when the native transport is used. +# This is similar to rpc_max_threads though the default differs slightly (and +# there is no native_transport_min_threads, idle threads will always be stopped +# after 30 seconds). +# native_transport_max_threads: 128 +# +# The maximum size of allowed frame. Frame (requests) larger than this will +# be rejected as invalid. The default is 256MB. If you're changing this parameter, +# you may want to adjust max_value_size_in_mb accordingly. This should be positive and less than 2048. +# native_transport_max_frame_size_in_mb: 256 + +# The maximum number of concurrent client connections. +# The default is -1, which means unlimited. +# native_transport_max_concurrent_connections: -1 + +# The maximum number of concurrent client connections per source ip. +# The default is -1, which means unlimited. +# native_transport_max_concurrent_connections_per_ip: -1 + +# Whether to start the thrift rpc server. +start_rpc: false + +# The address or interface to bind the Thrift RPC service and native transport +# server to. +# +# Set rpc_address OR rpc_interface, not both. +# +# Leaving rpc_address blank has the same effect as on listen_address +# (i.e. it will be based on the configured hostname of the node). +# +# Note that unlike listen_address, you can specify 0.0.0.0, but you must also +# set broadcast_rpc_address to a value other than 0.0.0.0. +# +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +rpc_address: 0.0.0.0 + +# Set rpc_address OR rpc_interface, not both. Interfaces must correspond +# to a single address, IP aliasing is not supported. +# rpc_interface: eth1 + +# If you choose to specify the interface by name and the interface has an ipv4 and an ipv6 address +# you can specify which should be chosen using rpc_interface_prefer_ipv6. If false the first ipv4 +# address will be used. If true the first ipv6 address will be used. Defaults to false preferring +# ipv4. If there is only one address it will be selected regardless of ipv4/ipv6. +# rpc_interface_prefer_ipv6: false + +# port for Thrift to listen for clients on +rpc_port: 9160 + +# RPC address to broadcast to drivers and other Cassandra nodes. This cannot +# be set to 0.0.0.0. If left blank, this will be set to the value of +# rpc_address. If rpc_address is set to 0.0.0.0, broadcast_rpc_address must +# be set. +broadcast_rpc_address: 172.17.0.2 + +# enable or disable keepalive on rpc/native connections +rpc_keepalive: true + +# Cassandra provides two out-of-the-box options for the RPC Server: +# +# sync +# One thread per thrift connection. For a very large number of clients, memory +# will be your limiting factor. On a 64 bit JVM, 180KB is the minimum stack size +# per thread, and that will correspond to your use of virtual memory (but physical memory +# may be limited depending on use of stack space). +# +# hsha +# Stands for "half synchronous, half asynchronous." All thrift clients are handled +# asynchronously using a small number of threads that does not vary with the amount +# of thrift clients (and thus scales well to many clients). The rpc requests are still +# synchronous (one thread per active request). If hsha is selected then it is essential +# that rpc_max_threads is changed from the default value of unlimited. +# +# The default is sync because on Windows hsha is about 30% slower. On Linux, +# sync/hsha performance is about the same, with hsha of course using less memory. +# +# Alternatively, can provide your own RPC server by providing the fully-qualified class name +# of an o.a.c.t.TServerFactory that can create an instance of it. +rpc_server_type: sync + +# Uncomment rpc_min|max_thread to set request pool size limits. +# +# Regardless of your choice of RPC server (see above), the number of maximum requests in the +# RPC thread pool dictates how many concurrent requests are possible (but if you are using the sync +# RPC server, it also dictates the number of clients that can be connected at all). +# +# The default is unlimited and thus provides no protection against clients overwhelming the server. You are +# encouraged to set a maximum that makes sense for you in production, but do keep in mind that +# rpc_max_threads represents the maximum number of client requests this server may execute concurrently. +# +# rpc_min_threads: 16 +# rpc_max_threads: 2048 + +# uncomment to set socket buffer sizes on rpc connections +# rpc_send_buff_size_in_bytes: +# rpc_recv_buff_size_in_bytes: + +# Uncomment to set socket buffer size for internode communication +# Note that when setting this, the buffer size is limited by net.core.wmem_max +# and when not setting it it is defined by net.ipv4.tcp_wmem +# See also: +# /proc/sys/net/core/wmem_max +# /proc/sys/net/core/rmem_max +# /proc/sys/net/ipv4/tcp_wmem +# /proc/sys/net/ipv4/tcp_wmem +# and 'man tcp' +# internode_send_buff_size_in_bytes: + +# Uncomment to set socket buffer size for internode communication +# Note that when setting this, the buffer size is limited by net.core.wmem_max +# and when not setting it it is defined by net.ipv4.tcp_wmem +# internode_recv_buff_size_in_bytes: + +# Frame size for thrift (maximum message length). +thrift_framed_transport_size_in_mb: 15 + +# Set to true to have Cassandra create a hard link to each sstable +# flushed or streamed locally in a backups/ subdirectory of the +# keyspace data. Removing these links is the operator's +# responsibility. +incremental_backups: false + +# Whether or not to take a snapshot before each compaction. Be +# careful using this option, since Cassandra won't clean up the +# snapshots for you. Mostly useful if you're paranoid when there +# is a data format change. +snapshot_before_compaction: false + +# Whether or not a snapshot is taken of the data before keyspace truncation +# or dropping of column families. The STRONGLY advised default of true +# should be used to provide data safety. If you set this flag to false, you will +# lose data on truncation or drop. +auto_snapshot: true + +# Granularity of the collation index of rows within a partition. +# Increase if your rows are large, or if you have a very large +# number of rows per partition. The competing goals are these: +# +# - a smaller granularity means more index entries are generated +# and looking up rows within the partition by collation column +# is faster +# - but, Cassandra will keep the collation index in memory for hot +# rows (as part of the key cache), so a larger granularity means +# you can cache more hot rows +column_index_size_in_kb: 64 + +# Per sstable indexed key cache entries (the collation index in memory +# mentioned above) exceeding this size will not be held on heap. +# This means that only partition information is held on heap and the +# index entries are read from disk. +# +# Note that this size refers to the size of the +# serialized index information and not the size of the partition. +column_index_cache_size_in_kb: 2 + +# Number of simultaneous compactions to allow, NOT including +# validation "compactions" for anti-entropy repair. Simultaneous +# compactions can help preserve read performance in a mixed read/write +# workload, by mitigating the tendency of small sstables to accumulate +# during a single long running compactions. The default is usually +# fine and if you experience problems with compaction running too +# slowly or too fast, you should look at +# compaction_throughput_mb_per_sec first. +# +# concurrent_compactors defaults to the smaller of (number of disks, +# number of cores), with a minimum of 2 and a maximum of 8. +# +# If your data directories are backed by SSD, you should increase this +# to the number of cores. +#concurrent_compactors: 1 + +# Throttles compaction to the given total throughput across the entire +# system. The faster you insert data, the faster you need to compact in +# order to keep the sstable count down, but in general, setting this to +# 16 to 32 times the rate you are inserting data is more than sufficient. +# Setting this to 0 disables throttling. Note that this account for all types +# of compaction, including validation compaction. +compaction_throughput_mb_per_sec: 16 + +# When compacting, the replacement sstable(s) can be opened before they +# are completely written, and used in place of the prior sstables for +# any range that has been written. This helps to smoothly transfer reads +# between the sstables, reducing page cache churn and keeping hot rows hot +sstable_preemptive_open_interval_in_mb: 50 + +# Throttles all outbound streaming file transfers on this node to the +# given total throughput in Mbps. This is necessary because Cassandra does +# mostly sequential IO when streaming data during bootstrap or repair, which +# can lead to saturating the network connection and degrading rpc performance. +# When unset, the default is 200 Mbps or 25 MB/s. +# stream_throughput_outbound_megabits_per_sec: 200 + +# Throttles all streaming file transfer between the datacenters, +# this setting allows users to throttle inter dc stream throughput in addition +# to throttling all network stream traffic as configured with +# stream_throughput_outbound_megabits_per_sec +# When unset, the default is 200 Mbps or 25 MB/s +# inter_dc_stream_throughput_outbound_megabits_per_sec: 200 + +# How long the coordinator should wait for read operations to complete +read_request_timeout_in_ms: 5000 +# How long the coordinator should wait for seq or index scans to complete +range_request_timeout_in_ms: 10000 +# How long the coordinator should wait for writes to complete +write_request_timeout_in_ms: 2000 +# How long the coordinator should wait for counter writes to complete +counter_write_request_timeout_in_ms: 5000 +# How long a coordinator should continue to retry a CAS operation +# that contends with other proposals for the same row +cas_contention_timeout_in_ms: 1000 +# How long the coordinator should wait for truncates to complete +# (This can be much longer, because unless auto_snapshot is disabled +# we need to flush first so we can snapshot before removing the data.) +truncate_request_timeout_in_ms: 60000 +# The default timeout for other, miscellaneous operations +request_timeout_in_ms: 10000 + +# How long before a node logs slow queries. Select queries that take longer than +# this timeout to execute, will generate an aggregated log message, so that slow queries +# can be identified. Set this value to zero to disable slow query logging. +slow_query_log_timeout_in_ms: 500 + +# Enable operation timeout information exchange between nodes to accurately +# measure request timeouts. If disabled, replicas will assume that requests +# were forwarded to them instantly by the coordinator, which means that +# under overload conditions we will waste that much extra time processing +# already-timed-out requests. +# +# Warning: before enabling this property make sure to ntp is installed +# and the times are synchronized between the nodes. +cross_node_timeout: false + +# Set keep-alive period for streaming +# This node will send a keep-alive message periodically with this period. +# If the node does not receive a keep-alive message from the peer for +# 2 keep-alive cycles the stream session times out and fail +# Default value is 300s (5 minutes), which means stalled stream +# times out in 10 minutes by default +# streaming_keep_alive_period_in_secs: 300 + +# phi value that must be reached for a host to be marked down. +# most users should never need to adjust this. +# phi_convict_threshold: 8 + +# endpoint_snitch -- Set this to a class that implements +# IEndpointSnitch. The snitch has two functions: +# +# - it teaches Cassandra enough about your network topology to route +# requests efficiently +# - it allows Cassandra to spread replicas around your cluster to avoid +# correlated failures. It does this by grouping machines into +# "datacenters" and "racks." Cassandra will do its best not to have +# more than one replica on the same "rack" (which may not actually +# be a physical location) +# +# CASSANDRA WILL NOT ALLOW YOU TO SWITCH TO AN INCOMPATIBLE SNITCH +# ONCE DATA IS INSERTED INTO THE CLUSTER. This would cause data loss. +# This means that if you start with the default SimpleSnitch, which +# locates every node on "rack1" in "datacenter1", your only options +# if you need to add another datacenter are GossipingPropertyFileSnitch +# (and the older PFS). From there, if you want to migrate to an +# incompatible snitch like Ec2Snitch you can do it by adding new nodes +# under Ec2Snitch (which will locate them in a new "datacenter") and +# decommissioning the old ones. +# +# Out of the box, Cassandra provides: +# +# SimpleSnitch: +# Treats Strategy order as proximity. This can improve cache +# locality when disabling read repair. Only appropriate for +# single-datacenter deployments. +# +# GossipingPropertyFileSnitch +# This should be your go-to snitch for production use. The rack +# and datacenter for the local node are defined in +# cassandra-rackdc.properties and propagated to other nodes via +# gossip. If cassandra-topology.properties exists, it is used as a +# fallback, allowing migration from the PropertyFileSnitch. +# +# PropertyFileSnitch: +# Proximity is determined by rack and data center, which are +# explicitly configured in cassandra-topology.properties. +# +# Ec2Snitch: +# Appropriate for EC2 deployments in a single Region. Loads Region +# and Availability Zone information from the EC2 API. The Region is +# treated as the datacenter, and the Availability Zone as the rack. +# Only private IPs are used, so this will not work across multiple +# Regions. +# +# Ec2MultiRegionSnitch: +# Uses public IPs as broadcast_address to allow cross-region +# connectivity. (Thus, you should set seed addresses to the public +# IP as well.) You will need to open the storage_port or +# ssl_storage_port on the public IP firewall. (For intra-Region +# traffic, Cassandra will switch to the private IP after +# establishing a connection.) +# +# RackInferringSnitch: +# Proximity is determined by rack and data center, which are +# assumed to correspond to the 3rd and 2nd octet of each node's IP +# address, respectively. Unless this happens to match your +# deployment conventions, this is best used as an example of +# writing a custom Snitch class and is provided in that spirit. +# +# You can use a custom Snitch by setting this to the full class name +# of the snitch, which will be assumed to be on your classpath. +endpoint_snitch: SimpleSnitch + +# controls how often to perform the more expensive part of host score +# calculation +dynamic_snitch_update_interval_in_ms: 100 +# controls how often to reset all host scores, allowing a bad host to +# possibly recover +dynamic_snitch_reset_interval_in_ms: 600000 +# if set greater than zero and read_repair_chance is < 1.0, this will allow +# 'pinning' of replicas to hosts in order to increase cache capacity. +# The badness threshold will control how much worse the pinned host has to be +# before the dynamic snitch will prefer other replicas over it. This is +# expressed as a double which represents a percentage. Thus, a value of +# 0.2 means Cassandra would continue to prefer the static snitch values +# until the pinned host was 20% worse than the fastest. +dynamic_snitch_badness_threshold: 0.1 + +# request_scheduler -- Set this to a class that implements +# RequestScheduler, which will schedule incoming client requests +# according to the specific policy. This is useful for multi-tenancy +# with a single Cassandra cluster. +# NOTE: This is specifically for requests from the client and does +# not affect inter node communication. +# org.apache.cassandra.scheduler.NoScheduler - No scheduling takes place +# org.apache.cassandra.scheduler.RoundRobinScheduler - Round robin of +# client requests to a node with a separate queue for each +# request_scheduler_id. The scheduler is further customized by +# request_scheduler_options as described below. +request_scheduler: org.apache.cassandra.scheduler.NoScheduler + +# Scheduler Options vary based on the type of scheduler +# +# NoScheduler +# Has no options +# +# RoundRobin +# throttle_limit +# The throttle_limit is the number of in-flight +# requests per client. Requests beyond +# that limit are queued up until +# running requests can complete. +# The value of 80 here is twice the number of +# concurrent_reads + concurrent_writes. +# default_weight +# default_weight is optional and allows for +# overriding the default which is 1. +# weights +# Weights are optional and will default to 1 or the +# overridden default_weight. The weight translates into how +# many requests are handled during each turn of the +# RoundRobin, based on the scheduler id. +# +# request_scheduler_options: +# throttle_limit: 80 +# default_weight: 5 +# weights: +# Keyspace1: 1 +# Keyspace2: 5 + +# request_scheduler_id -- An identifier based on which to perform +# the request scheduling. Currently the only valid option is keyspace. +# request_scheduler_id: keyspace + +# Enable or disable inter-node encryption +# JVM defaults for supported SSL socket protocols and cipher suites can +# be replaced using custom encryption options. This is not recommended +# unless you have policies in place that dictate certain settings, or +# need to disable vulnerable ciphers or protocols in case the JVM cannot +# be updated. +# FIPS compliant settings can be configured at JVM level and should not +# involve changing encryption settings here: +# https://docs.oracle.com/javase/8/docs/technotes/guides/security/jsse/FIPS.html +# *NOTE* No custom encryption options are enabled at the moment +# The available internode options are : all, none, dc, rack +# +# If set to dc cassandra will encrypt the traffic between the DCs +# If set to rack cassandra will encrypt the traffic between the racks +# +# The passwords used in these options must match the passwords used when generating +# the keystore and truststore. For instructions on generating these files, see: +# http://download.oracle.com/javase/6/docs/technotes/guides/security/jsse/JSSERefGuide.html#CreateKeystore +# +server_encryption_options: + internode_encryption: none + keystore: conf/.keystore + keystore_password: cassandra + truststore: conf/.truststore + truststore_password: cassandra + # More advanced defaults below: + # protocol: TLS + # algorithm: SunX509 + # store_type: JKS + # cipher_suites: [TLS_RSA_WITH_AES_128_CBC_SHA,TLS_RSA_WITH_AES_256_CBC_SHA,TLS_DHE_RSA_WITH_AES_128_CBC_SHA,TLS_DHE_RSA_WITH_AES_256_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA] + # require_client_auth: false + # require_endpoint_verification: false + +# enable or disable client/server encryption. +client_encryption_options: + enabled: false + # If enabled and optional is set to true encrypted and unencrypted connections are handled. + optional: false + keystore: conf/.keystore + keystore_password: cassandra + # require_client_auth: false + # Set trustore and truststore_password if require_client_auth is true + # truststore: conf/.truststore + # truststore_password: cassandra + # More advanced defaults below: + # protocol: TLS + # algorithm: SunX509 + # store_type: JKS + # cipher_suites: [TLS_RSA_WITH_AES_128_CBC_SHA,TLS_RSA_WITH_AES_256_CBC_SHA,TLS_DHE_RSA_WITH_AES_128_CBC_SHA,TLS_DHE_RSA_WITH_AES_256_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA] + +# internode_compression controls whether traffic between nodes is +# compressed. +# Can be: +# +# all +# all traffic is compressed +# +# dc +# traffic between different datacenters is compressed +# +# none +# nothing is compressed. +internode_compression: dc + +# Enable or disable tcp_nodelay for inter-dc communication. +# Disabling it will result in larger (but fewer) network packets being sent, +# reducing overhead from the TCP protocol itself, at the cost of increasing +# latency if you block for cross-datacenter responses. +inter_dc_tcp_nodelay: false + +# TTL for different trace types used during logging of the repair process. +tracetype_query_ttl: 86400 +tracetype_repair_ttl: 604800 + +# By default, Cassandra logs GC Pauses greater than 200 ms at INFO level +# This threshold can be adjusted to minimize logging if necessary +# gc_log_threshold_in_ms: 200 + +# If unset, all GC Pauses greater than gc_log_threshold_in_ms will log at +# INFO level +# UDFs (user defined functions) are disabled by default. +# As of Cassandra 3.0 there is a sandbox in place that should prevent execution of evil code. +enable_user_defined_functions: false + +# Enables scripted UDFs (JavaScript UDFs). +# Java UDFs are always enabled, if enable_user_defined_functions is true. +# Enable this option to be able to use UDFs with "language javascript" or any custom JSR-223 provider. +# This option has no effect, if enable_user_defined_functions is false. +enable_scripted_user_defined_functions: false + +# The default Windows kernel timer and scheduling resolution is 15.6ms for power conservation. +# Lowering this value on Windows can provide much tighter latency and better throughput, however +# some virtualized environments may see a negative performance impact from changing this setting +# below their system default. The sysinternals 'clockres' tool can confirm your system's default +# setting. +windows_timer_interval: 1 + + +# Enables encrypting data at-rest (on disk). Different key providers can be plugged in, but the default reads from +# a JCE-style keystore. A single keystore can hold multiple keys, but the one referenced by +# the "key_alias" is the only key that will be used for encrypt operations; previously used keys +# can still (and should!) be in the keystore and will be used on decrypt operations +# (to handle the case of key rotation). +# +# It is strongly recommended to download and install Java Cryptography Extension (JCE) +# Unlimited Strength Jurisdiction Policy Files for your version of the JDK. +# (current link: http://www.oracle.com/technetwork/java/javase/downloads/jce8-download-2133166.html) +# +# Currently, only the following file types are supported for transparent data encryption, although +# more are coming in future cassandra releases: commitlog, hints +transparent_data_encryption_options: + enabled: false + chunk_length_kb: 64 + cipher: AES/CBC/PKCS5Padding + key_alias: testing:1 + # CBC IV length for AES needs to be 16 bytes (which is also the default size) + # iv_length: 16 + key_provider: + - class_name: org.apache.cassandra.security.JKSKeyProvider + parameters: + - keystore: conf/.keystore + keystore_password: cassandra + store_type: JCEKS + key_password: cassandra + + +##################### +# SAFETY THRESHOLDS # +##################### + +# When executing a scan, within or across a partition, we need to keep the +# tombstones seen in memory so we can return them to the coordinator, which +# will use them to make sure other replicas also know about the deleted rows. +# With workloads that generate a lot of tombstones, this can cause performance +# problems and even exhaust the server heap. +# (http://www.datastax.com/dev/blog/cassandra-anti-patterns-queues-and-queue-like-datasets) +# Adjust the thresholds here if you understand the dangers and want to +# scan more tombstones anyway. These thresholds may also be adjusted at runtime +# using the StorageService mbean. +tombstone_warn_threshold: 1000 +tombstone_failure_threshold: 100000 + +# Log WARN on any multiple-partition batch size exceeding this value. 5kb per batch by default. +# Caution should be taken on increasing the size of this threshold as it can lead to node instability. +batch_size_warn_threshold_in_kb: 5 + +# Fail any multiple-partition batch exceeding this value. 50kb (10x warn threshold) by default. +batch_size_fail_threshold_in_kb: 50 + +# Log WARN on any batches not of type LOGGED than span across more partitions than this limit +unlogged_batch_across_partitions_warn_threshold: 10 + +# Log a warning when compacting partitions larger than this value +compaction_large_partition_warning_threshold_mb: 100 + +# GC Pauses greater than gc_warn_threshold_in_ms will be logged at WARN level +# Adjust the threshold based on your application throughput requirement +# By default, Cassandra logs GC Pauses greater than 200 ms at INFO level +gc_warn_threshold_in_ms: 1000 + +# Maximum size of any value in SSTables. Safety measure to detect SSTable corruption +# early. Any value size larger than this threshold will result into marking an SSTable +# as corrupted. This should be positive and less than 2048. +# max_value_size_in_mb: 256 + +# Back-pressure settings # +# If enabled, the coordinator will apply the back-pressure strategy specified below to each mutation +# sent to replicas, with the aim of reducing pressure on overloaded replicas. +back_pressure_enabled: false +# The back-pressure strategy applied. +# The default implementation, RateBasedBackPressure, takes three arguments: +# high ratio, factor, and flow type, and uses the ratio between incoming mutation responses and outgoing mutation requests. +# If below high ratio, outgoing mutations are rate limited according to the incoming rate decreased by the given factor; +# if above high ratio, the rate limiting is increased by the given factor; +# such factor is usually best configured between 1 and 10, use larger values for a faster recovery +# at the expense of potentially more dropped mutations; +# the rate limiting is applied according to the flow type: if FAST, it's rate limited at the speed of the fastest replica, +# if SLOW at the speed of the slowest one. +# New strategies can be added. Implementors need to implement org.apache.cassandra.net.BackpressureStrategy and +# provide a public constructor accepting a Map. +back_pressure_strategy: + - class_name: org.apache.cassandra.net.RateBasedBackPressure + parameters: + - high_ratio: 0.90 + factor: 5 + flow: FAST + +# Coalescing Strategies # +# Coalescing multiples messages turns out to significantly boost message processing throughput (think doubling or more). +# On bare metal, the floor for packet processing throughput is high enough that many applications won't notice, but in +# virtualized environments, the point at which an application can be bound by network packet processing can be +# surprisingly low compared to the throughput of task processing that is possible inside a VM. It's not that bare metal +# doesn't benefit from coalescing messages, it's that the number of packets a bare metal network interface can process +# is sufficient for many applications such that no load starvation is experienced even without coalescing. +# There are other benefits to coalescing network messages that are harder to isolate with a simple metric like messages +# per second. By coalescing multiple tasks together, a network thread can process multiple messages for the cost of one +# trip to read from a socket, and all the task submission work can be done at the same time reducing context switching +# and increasing cache friendliness of network message processing. +# See CASSANDRA-8692 for details. + +# Strategy to use for coalescing messages in OutboundTcpConnection. +# Can be fixed, movingaverage, timehorizon, disabled (default). +# You can also specify a subclass of CoalescingStrategies.CoalescingStrategy by name. +# otc_coalescing_strategy: DISABLED + +# How many microseconds to wait for coalescing. For fixed strategy this is the amount of time after the first +# message is received before it will be sent with any accompanying messages. For moving average this is the +# maximum amount of time that will be waited as well as the interval at which messages must arrive on average +# for coalescing to be enabled. +# otc_coalescing_window_us: 200 + +# Do not try to coalesce messages if we already got that many messages. This should be more than 2 and less than 128. +# otc_coalescing_enough_coalesced_messages: 8 + +# How many milliseconds to wait between two expiration runs on the backlog (queue) of the OutboundTcpConnection. +# Expiration is done if messages are piling up in the backlog. Droppable messages are expired to free the memory +# taken by expired messages. The interval should be between 0 and 1000, and in most installations the default value +# will be appropriate. A smaller value could potentially expire messages slightly sooner at the expense of more CPU +# time and queue contention while iterating the backlog of messages. +# An interval of 0 disables any wait time, which is the behavior of former Cassandra versions. +# +# otc_backlog_expiration_interval_ms: 200 diff --git a/modules/cassandra/src/test/resources/cassandra-ssl-configuration/cassandra.cer b/modules/cassandra/src/test/resources/cassandra-ssl-configuration/cassandra.cer new file mode 100644 index 00000000000..7a6aee6dec4 Binary files /dev/null and b/modules/cassandra/src/test/resources/cassandra-ssl-configuration/cassandra.cer differ diff --git a/modules/cassandra/src/test/resources/cassandra-ssl-configuration/cassandra.yaml b/modules/cassandra/src/test/resources/cassandra-ssl-configuration/cassandra.yaml new file mode 100644 index 00000000000..116ba3429cf --- /dev/null +++ b/modules/cassandra/src/test/resources/cassandra-ssl-configuration/cassandra.yaml @@ -0,0 +1,1233 @@ +# Cassandra storage config YAML + +# NOTE: +# See http://wiki.apache.org/cassandra/StorageConfiguration for +# full explanations of configuration directives +# /NOTE + +# The name of the cluster. This is mainly used to prevent machines in +# one logical cluster from joining another. +cluster_name: 'Test Cluster Integration Test' + +# This defines the number of tokens randomly assigned to this node on the ring +# The more tokens, relative to other nodes, the larger the proportion of data +# that this node will store. You probably want all nodes to have the same number +# of tokens assuming they have equal hardware capability. +# +# If you leave this unspecified, Cassandra will use the default of 1 token for legacy compatibility, +# and will use the initial_token as described below. +# +# Specifying initial_token will override this setting on the node's initial start, +# on subsequent starts, this setting will apply even if initial token is set. +# +# If you already have a cluster with 1 token per node, and wish to migrate to +# multiple tokens per node, see http://wiki.apache.org/cassandra/Operations +num_tokens: 256 + +# Triggers automatic allocation of num_tokens tokens for this node. The allocation +# algorithm attempts to choose tokens in a way that optimizes replicated load over +# the nodes in the datacenter for the replication strategy used by the specified +# keyspace. +# +# The load assigned to each node will be close to proportional to its number of +# vnodes. +# +# Only supported with the Murmur3Partitioner. +# allocate_tokens_for_keyspace: KEYSPACE + +# initial_token allows you to specify tokens manually. While you can use it with +# vnodes (num_tokens > 1, above) -- in which case you should provide a +# comma-separated list -- it's primarily used when adding nodes to legacy clusters +# that do not have vnodes enabled. +# initial_token: + +# See http://wiki.apache.org/cassandra/HintedHandoff +# May either be "true" or "false" to enable globally +hinted_handoff_enabled: true + +# When hinted_handoff_enabled is true, a black list of data centers that will not +# perform hinted handoff +# hinted_handoff_disabled_datacenters: +# - DC1 +# - DC2 + +# this defines the maximum amount of time a dead host will have hints +# generated. After it has been dead this long, new hints for it will not be +# created until it has been seen alive and gone down again. +max_hint_window_in_ms: 10800000 # 3 hours + +# Maximum throttle in KBs per second, per delivery thread. This will be +# reduced proportionally to the number of nodes in the cluster. (If there +# are two nodes in the cluster, each delivery thread will use the maximum +# rate; if there are three, each will throttle to half of the maximum, +# since we expect two nodes to be delivering hints simultaneously.) +hinted_handoff_throttle_in_kb: 1024 + +# Number of threads with which to deliver hints; +# Consider increasing this number when you have multi-dc deployments, since +# cross-dc handoff tends to be slower +max_hints_delivery_threads: 2 + +# Directory where Cassandra should store hints. +# If not set, the default directory is $CASSANDRA_HOME/data/hints. +# hints_directory: /var/lib/cassandra/hints + +# How often hints should be flushed from the internal buffers to disk. +# Will *not* trigger fsync. +hints_flush_period_in_ms: 10000 + +# Maximum size for a single hints file, in megabytes. +max_hints_file_size_in_mb: 128 + +# Compression to apply to the hint files. If omitted, hints files +# will be written uncompressed. LZ4, Snappy, and Deflate compressors +# are supported. +#hints_compression: +# - class_name: LZ4Compressor +# parameters: +# - + +# Maximum throttle in KBs per second, total. This will be +# reduced proportionally to the number of nodes in the cluster. +batchlog_replay_throttle_in_kb: 1024 + +# Authentication backend, implementing IAuthenticator; used to identify users +# Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllAuthenticator, +# PasswordAuthenticator}. +# +# - AllowAllAuthenticator performs no checks - set it to disable authentication. +# - PasswordAuthenticator relies on username/password pairs to authenticate +# users. It keeps usernames and hashed passwords in system_auth.roles table. +# Please increase system_auth keyspace replication factor if you use this authenticator. +# If using PasswordAuthenticator, CassandraRoleManager must also be used (see below) +authenticator: AllowAllAuthenticator + +# Authorization backend, implementing IAuthorizer; used to limit access/provide permissions +# Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllAuthorizer, +# CassandraAuthorizer}. +# +# - AllowAllAuthorizer allows any action to any user - set it to disable authorization. +# - CassandraAuthorizer stores permissions in system_auth.role_permissions table. Please +# increase system_auth keyspace replication factor if you use this authorizer. +authorizer: AllowAllAuthorizer + +# Part of the Authentication & Authorization backend, implementing IRoleManager; used +# to maintain grants and memberships between roles. +# Out of the box, Cassandra provides org.apache.cassandra.auth.CassandraRoleManager, +# which stores role information in the system_auth keyspace. Most functions of the +# IRoleManager require an authenticated login, so unless the configured IAuthenticator +# actually implements authentication, most of this functionality will be unavailable. +# +# - CassandraRoleManager stores role data in the system_auth keyspace. Please +# increase system_auth keyspace replication factor if you use this role manager. +role_manager: CassandraRoleManager + +# Validity period for roles cache (fetching granted roles can be an expensive +# operation depending on the role manager, CassandraRoleManager is one example) +# Granted roles are cached for authenticated sessions in AuthenticatedUser and +# after the period specified here, become eligible for (async) reload. +# Defaults to 2000, set to 0 to disable caching entirely. +# Will be disabled automatically for AllowAllAuthenticator. +roles_validity_in_ms: 2000 + +# Refresh interval for roles cache (if enabled). +# After this interval, cache entries become eligible for refresh. Upon next +# access, an async reload is scheduled and the old value returned until it +# completes. If roles_validity_in_ms is non-zero, then this must be +# also. +# Defaults to the same value as roles_validity_in_ms. +# roles_update_interval_in_ms: 2000 + +# Validity period for permissions cache (fetching permissions can be an +# expensive operation depending on the authorizer, CassandraAuthorizer is +# one example). Defaults to 2000, set to 0 to disable. +# Will be disabled automatically for AllowAllAuthorizer. +permissions_validity_in_ms: 2000 + +# Refresh interval for permissions cache (if enabled). +# After this interval, cache entries become eligible for refresh. Upon next +# access, an async reload is scheduled and the old value returned until it +# completes. If permissions_validity_in_ms is non-zero, then this must be +# also. +# Defaults to the same value as permissions_validity_in_ms. +# permissions_update_interval_in_ms: 2000 + +# Validity period for credentials cache. This cache is tightly coupled to +# the provided PasswordAuthenticator implementation of IAuthenticator. If +# another IAuthenticator implementation is configured, this cache will not +# be automatically used and so the following settings will have no effect. +# Please note, credentials are cached in their encrypted form, so while +# activating this cache may reduce the number of queries made to the +# underlying table, it may not bring a significant reduction in the +# latency of individual authentication attempts. +# Defaults to 2000, set to 0 to disable credentials caching. +credentials_validity_in_ms: 2000 + +# Refresh interval for credentials cache (if enabled). +# After this interval, cache entries become eligible for refresh. Upon next +# access, an async reload is scheduled and the old value returned until it +# completes. If credentials_validity_in_ms is non-zero, then this must be +# also. +# Defaults to the same value as credentials_validity_in_ms. +# credentials_update_interval_in_ms: 2000 + +# The partitioner is responsible for distributing groups of rows (by +# partition key) across nodes in the cluster. You should leave this +# alone for new clusters. The partitioner can NOT be changed without +# reloading all data, so when upgrading you should set this to the +# same partitioner you were already using. +# +# Besides Murmur3Partitioner, partitioners included for backwards +# compatibility include RandomPartitioner, ByteOrderedPartitioner, and +# OrderPreservingPartitioner. +# +partitioner: org.apache.cassandra.dht.Murmur3Partitioner + +# Directories where Cassandra should store data on disk. Cassandra +# will spread data evenly across them, subject to the granularity of +# the configured compaction strategy. +# If not set, the default directory is $CASSANDRA_HOME/data/data. +data_file_directories: + - /var/lib/cassandra/data + +# commit log. when running on magnetic HDD, this should be a +# separate spindle than the data directories. +# If not set, the default directory is $CASSANDRA_HOME/data/commitlog. +commitlog_directory: /var/lib/cassandra/commitlog + +# Enable / disable CDC functionality on a per-node basis. This modifies the logic used +# for write path allocation rejection (standard: never reject. cdc: reject Mutation +# containing a CDC-enabled table if at space limit in cdc_raw_directory). +cdc_enabled: false + +# CommitLogSegments are moved to this directory on flush if cdc_enabled: true and the +# segment contains mutations for a CDC-enabled table. This should be placed on a +# separate spindle than the data directories. If not set, the default directory is +# $CASSANDRA_HOME/data/cdc_raw. +# cdc_raw_directory: /var/lib/cassandra/cdc_raw + +# Policy for data disk failures: +# +# die +# shut down gossip and client transports and kill the JVM for any fs errors or +# single-sstable errors, so the node can be replaced. +# +# stop_paranoid +# shut down gossip and client transports even for single-sstable errors, +# kill the JVM for errors during startup. +# +# stop +# shut down gossip and client transports, leaving the node effectively dead, but +# can still be inspected via JMX, kill the JVM for errors during startup. +# +# best_effort +# stop using the failed disk and respond to requests based on +# remaining available sstables. This means you WILL see obsolete +# data at CL.ONE! +# +# ignore +# ignore fatal errors and let requests fail, as in pre-1.2 Cassandra +disk_failure_policy: stop + +# Policy for commit disk failures: +# +# die +# shut down gossip and Thrift and kill the JVM, so the node can be replaced. +# +# stop +# shut down gossip and Thrift, leaving the node effectively dead, but +# can still be inspected via JMX. +# +# stop_commit +# shutdown the commit log, letting writes collect but +# continuing to service reads, as in pre-2.0.5 Cassandra +# +# ignore +# ignore fatal errors and let the batches fail +commit_failure_policy: stop + +# Maximum size of the native protocol prepared statement cache +# +# Valid values are either "auto" (omitting the value) or a value greater 0. +# +# Note that specifying a too large value will result in long running GCs and possbily +# out-of-memory errors. Keep the value at a small fraction of the heap. +# +# If you constantly see "prepared statements discarded in the last minute because +# cache limit reached" messages, the first step is to investigate the root cause +# of these messages and check whether prepared statements are used correctly - +# i.e. use bind markers for variable parts. +# +# Do only change the default value, if you really have more prepared statements than +# fit in the cache. In most cases it is not neccessary to change this value. +# Constantly re-preparing statements is a performance penalty. +# +# Default value ("auto") is 1/256th of the heap or 10MB, whichever is greater +prepared_statements_cache_size_mb: + +# Maximum size of the Thrift prepared statement cache +# +# If you do not use Thrift at all, it is safe to leave this value at "auto". +# +# See description of 'prepared_statements_cache_size_mb' above for more information. +# +# Default value ("auto") is 1/256th of the heap or 10MB, whichever is greater +thrift_prepared_statements_cache_size_mb: + +# Maximum size of the key cache in memory. +# +# Each key cache hit saves 1 seek and each row cache hit saves 2 seeks at the +# minimum, sometimes more. The key cache is fairly tiny for the amount of +# time it saves, so it's worthwhile to use it at large numbers. +# The row cache saves even more time, but must contain the entire row, +# so it is extremely space-intensive. It's best to only use the +# row cache if you have hot rows or static rows. +# +# NOTE: if you reduce the size, you may not get you hottest keys loaded on startup. +# +# Default value is empty to make it "auto" (min(5% of Heap (in MB), 100MB)). Set to 0 to disable key cache. +key_cache_size_in_mb: + +# Duration in seconds after which Cassandra should +# save the key cache. Caches are saved to saved_caches_directory as +# specified in this configuration file. +# +# Saved caches greatly improve cold-start speeds, and is relatively cheap in +# terms of I/O for the key cache. Row cache saving is much more expensive and +# has limited use. +# +# Default is 14400 or 4 hours. +key_cache_save_period: 14400 + +# Number of keys from the key cache to save +# Disabled by default, meaning all keys are going to be saved +# key_cache_keys_to_save: 100 + +# Row cache implementation class name. Available implementations: +# +# org.apache.cassandra.cache.OHCProvider +# Fully off-heap row cache implementation (default). +# +# org.apache.cassandra.cache.SerializingCacheProvider +# This is the row cache implementation availabile +# in previous releases of Cassandra. +# row_cache_class_name: org.apache.cassandra.cache.OHCProvider + +# Maximum size of the row cache in memory. +# Please note that OHC cache implementation requires some additional off-heap memory to manage +# the map structures and some in-flight memory during operations before/after cache entries can be +# accounted against the cache capacity. This overhead is usually small compared to the whole capacity. +# Do not specify more memory that the system can afford in the worst usual situation and leave some +# headroom for OS block level cache. Do never allow your system to swap. +# +# Default value is 0, to disable row caching. +row_cache_size_in_mb: 0 + +# Duration in seconds after which Cassandra should save the row cache. +# Caches are saved to saved_caches_directory as specified in this configuration file. +# +# Saved caches greatly improve cold-start speeds, and is relatively cheap in +# terms of I/O for the key cache. Row cache saving is much more expensive and +# has limited use. +# +# Default is 0 to disable saving the row cache. +row_cache_save_period: 0 + +# Number of keys from the row cache to save. +# Specify 0 (which is the default), meaning all keys are going to be saved +# row_cache_keys_to_save: 100 + +# Maximum size of the counter cache in memory. +# +# Counter cache helps to reduce counter locks' contention for hot counter cells. +# In case of RF = 1 a counter cache hit will cause Cassandra to skip the read before +# write entirely. With RF > 1 a counter cache hit will still help to reduce the duration +# of the lock hold, helping with hot counter cell updates, but will not allow skipping +# the read entirely. Only the local (clock, count) tuple of a counter cell is kept +# in memory, not the whole counter, so it's relatively cheap. +# +# NOTE: if you reduce the size, you may not get you hottest keys loaded on startup. +# +# Default value is empty to make it "auto" (min(2.5% of Heap (in MB), 50MB)). Set to 0 to disable counter cache. +# NOTE: if you perform counter deletes and rely on low gcgs, you should disable the counter cache. +counter_cache_size_in_mb: + +# Duration in seconds after which Cassandra should +# save the counter cache (keys only). Caches are saved to saved_caches_directory as +# specified in this configuration file. +# +# Default is 7200 or 2 hours. +counter_cache_save_period: 7200 + +# Number of keys from the counter cache to save +# Disabled by default, meaning all keys are going to be saved +# counter_cache_keys_to_save: 100 + +# saved caches +# If not set, the default directory is $CASSANDRA_HOME/data/saved_caches. +saved_caches_directory: /var/lib/cassandra/saved_caches + +# commitlog_sync may be either "periodic" or "batch." +# +# When in batch mode, Cassandra won't ack writes until the commit log +# has been fsynced to disk. It will wait +# commitlog_sync_batch_window_in_ms milliseconds between fsyncs. +# This window should be kept short because the writer threads will +# be unable to do extra work while waiting. (You may need to increase +# concurrent_writes for the same reason.) +# +# commitlog_sync: batch +# commitlog_sync_batch_window_in_ms: 2 +# +# the other option is "periodic" where writes may be acked immediately +# and the CommitLog is simply synced every commitlog_sync_period_in_ms +# milliseconds. +commitlog_sync: periodic +commitlog_sync_period_in_ms: 10000 + +# The size of the individual commitlog file segments. A commitlog +# segment may be archived, deleted, or recycled once all the data +# in it (potentially from each columnfamily in the system) has been +# flushed to sstables. +# +# The default size is 32, which is almost always fine, but if you are +# archiving commitlog segments (see commitlog_archiving.properties), +# then you probably want a finer granularity of archiving; 8 or 16 MB +# is reasonable. +# Max mutation size is also configurable via max_mutation_size_in_kb setting in +# cassandra.yaml. The default is half the size commitlog_segment_size_in_mb * 1024. +# This should be positive and less than 2048. +# +# NOTE: If max_mutation_size_in_kb is set explicitly then commitlog_segment_size_in_mb must +# be set to at least twice the size of max_mutation_size_in_kb / 1024 +# +commitlog_segment_size_in_mb: 32 + +# Compression to apply to the commit log. If omitted, the commit log +# will be written uncompressed. LZ4, Snappy, and Deflate compressors +# are supported. +# commitlog_compression: +# - class_name: LZ4Compressor +# parameters: +# - + +# any class that implements the SeedProvider interface and has a +# constructor that takes a Map of parameters will do. +seed_provider: + # Addresses of hosts that are deemed contact points. + # Cassandra nodes use this list of hosts to find each other and learn + # the topology of the ring. You must change this if you are running + # multiple nodes! + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + # seeds is actually a comma-delimited list of addresses. + # Ex: ",," + - seeds: "172.17.0.2" + +# For workloads with more data than can fit in memory, Cassandra's +# bottleneck will be reads that need to fetch data from +# disk. "concurrent_reads" should be set to (16 * number_of_drives) in +# order to allow the operations to enqueue low enough in the stack +# that the OS and drives can reorder them. Same applies to +# "concurrent_counter_writes", since counter writes read the current +# values before incrementing and writing them back. +# +# On the other hand, since writes are almost never IO bound, the ideal +# number of "concurrent_writes" is dependent on the number of cores in +# your system; (8 * number_of_cores) is a good rule of thumb. +concurrent_reads: 32 +concurrent_writes: 32 +concurrent_counter_writes: 32 + +# For materialized view writes, as there is a read involved, so this should +# be limited by the less of concurrent reads or concurrent writes. +concurrent_materialized_view_writes: 32 + +# Maximum memory to use for sstable chunk cache and buffer pooling. +# 32MB of this are reserved for pooling buffers, the rest is used as an +# cache that holds uncompressed sstable chunks. +# Defaults to the smaller of 1/4 of heap or 512MB. This pool is allocated off-heap, +# so is in addition to the memory allocated for heap. The cache also has on-heap +# overhead which is roughly 128 bytes per chunk (i.e. 0.2% of the reserved size +# if the default 64k chunk size is used). +# Memory is only allocated when needed. +# file_cache_size_in_mb: 512 + +# Flag indicating whether to allocate on or off heap when the sstable buffer +# pool is exhausted, that is when it has exceeded the maximum memory +# file_cache_size_in_mb, beyond which it will not cache buffers but allocate on request. + +# buffer_pool_use_heap_if_exhausted: true + +# The strategy for optimizing disk read +# Possible values are: +# ssd (for solid state disks, the default) +# spinning (for spinning disks) +# disk_optimization_strategy: ssd + +# Total permitted memory to use for memtables. Cassandra will stop +# accepting writes when the limit is exceeded until a flush completes, +# and will trigger a flush based on memtable_cleanup_threshold +# If omitted, Cassandra will set both to 1/4 the size of the heap. +# memtable_heap_space_in_mb: 2048 +# memtable_offheap_space_in_mb: 2048 + +# memtable_cleanup_threshold is deprecated. The default calculation +# is the only reasonable choice. See the comments on memtable_flush_writers +# for more information. +# +# Ratio of occupied non-flushing memtable size to total permitted size +# that will trigger a flush of the largest memtable. Larger mct will +# mean larger flushes and hence less compaction, but also less concurrent +# flush activity which can make it difficult to keep your disks fed +# under heavy write load. +# +# memtable_cleanup_threshold defaults to 1 / (memtable_flush_writers + 1) +# memtable_cleanup_threshold: 0.11 + +# Specify the way Cassandra allocates and manages memtable memory. +# Options are: +# +# heap_buffers +# on heap nio buffers +# +# offheap_buffers +# off heap (direct) nio buffers +# +# offheap_objects +# off heap objects +memtable_allocation_type: heap_buffers + +# Total space to use for commit logs on disk. +# +# If space gets above this value, Cassandra will flush every dirty CF +# in the oldest segment and remove it. So a small total commitlog space +# will tend to cause more flush activity on less-active columnfamilies. +# +# The default value is the smaller of 8192, and 1/4 of the total space +# of the commitlog volume. +# +# commitlog_total_space_in_mb: 8192 + +# This sets the number of memtable flush writer threads per disk +# as well as the total number of memtables that can be flushed concurrently. +# These are generally a combination of compute and IO bound. +# +# Memtable flushing is more CPU efficient than memtable ingest and a single thread +# can keep up with the ingest rate of a whole server on a single fast disk +# until it temporarily becomes IO bound under contention typically with compaction. +# At that point you need multiple flush threads. At some point in the future +# it may become CPU bound all the time. +# +# You can tell if flushing is falling behind using the MemtablePool.BlockedOnAllocation +# metric which should be 0, but will be non-zero if threads are blocked waiting on flushing +# to free memory. +# +# memtable_flush_writers defaults to two for a single data directory. +# This means that two memtables can be flushed concurrently to the single data directory. +# If you have multiple data directories the default is one memtable flushing at a time +# but the flush will use a thread per data directory so you will get two or more writers. +# +# Two is generally enough to flush on a fast disk [array] mounted as a single data directory. +# Adding more flush writers will result in smaller more frequent flushes that introduce more +# compaction overhead. +# +# There is a direct tradeoff between number of memtables that can be flushed concurrently +# and flush size and frequency. More is not better you just need enough flush writers +# to never stall waiting for flushing to free memory. +# +#memtable_flush_writers: 2 + +# Total space to use for change-data-capture logs on disk. +# +# If space gets above this value, Cassandra will throw WriteTimeoutException +# on Mutations including tables with CDC enabled. A CDCCompactor is responsible +# for parsing the raw CDC logs and deleting them when parsing is completed. +# +# The default value is the min of 4096 mb and 1/8th of the total space +# of the drive where cdc_raw_directory resides. +# cdc_total_space_in_mb: 4096 + +# When we hit our cdc_raw limit and the CDCCompactor is either running behind +# or experiencing backpressure, we check at the following interval to see if any +# new space for cdc-tracked tables has been made available. Default to 250ms +# cdc_free_space_check_interval_ms: 250 + +# A fixed memory pool size in MB for for SSTable index summaries. If left +# empty, this will default to 5% of the heap size. If the memory usage of +# all index summaries exceeds this limit, SSTables with low read rates will +# shrink their index summaries in order to meet this limit. However, this +# is a best-effort process. In extreme conditions Cassandra may need to use +# more than this amount of memory. +index_summary_capacity_in_mb: + +# How frequently index summaries should be resampled. This is done +# periodically to redistribute memory from the fixed-size pool to sstables +# proportional their recent read rates. Setting to -1 will disable this +# process, leaving existing index summaries at their current sampling level. +index_summary_resize_interval_in_minutes: 60 + +# Whether to, when doing sequential writing, fsync() at intervals in +# order to force the operating system to flush the dirty +# buffers. Enable this to avoid sudden dirty buffer flushing from +# impacting read latencies. Almost always a good idea on SSDs; not +# necessarily on platters. +trickle_fsync: false +trickle_fsync_interval_in_kb: 10240 + +# TCP port, for commands and data +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +storage_port: 7000 + +# SSL port, for encrypted communication. Unused unless enabled in +# encryption_options +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +ssl_storage_port: 7001 + +# Address or interface to bind to and tell other Cassandra nodes to connect to. +# You _must_ change this if you want multiple nodes to be able to communicate! +# +# Set listen_address OR listen_interface, not both. +# +# Leaving it blank leaves it up to InetAddress.getLocalHost(). This +# will always do the Right Thing _if_ the node is properly configured +# (hostname, name resolution, etc), and the Right Thing is to use the +# address associated with the hostname (it might not be). +# +# Setting listen_address to 0.0.0.0 is always wrong. +# +listen_address: 172.17.0.2 + +# Set listen_address OR listen_interface, not both. Interfaces must correspond +# to a single address, IP aliasing is not supported. +# listen_interface: eth0 + +# If you choose to specify the interface by name and the interface has an ipv4 and an ipv6 address +# you can specify which should be chosen using listen_interface_prefer_ipv6. If false the first ipv4 +# address will be used. If true the first ipv6 address will be used. Defaults to false preferring +# ipv4. If there is only one address it will be selected regardless of ipv4/ipv6. +# listen_interface_prefer_ipv6: false + +# Address to broadcast to other Cassandra nodes +# Leaving this blank will set it to the same value as listen_address +broadcast_address: 172.17.0.2 + +# When using multiple physical network interfaces, set this +# to true to listen on broadcast_address in addition to +# the listen_address, allowing nodes to communicate in both +# interfaces. +# Ignore this property if the network configuration automatically +# routes between the public and private networks such as EC2. +# listen_on_broadcast_address: false + +# Internode authentication backend, implementing IInternodeAuthenticator; +# used to allow/disallow connections from peer nodes. +# internode_authenticator: org.apache.cassandra.auth.AllowAllInternodeAuthenticator + +# Whether to start the native transport server. +# Please note that the address on which the native transport is bound is the +# same as the rpc_address. The port however is different and specified below. +start_native_transport: true +# port for the CQL native transport to listen for clients on +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +native_transport_port: 9042 +# Enabling native transport encryption in client_encryption_options allows you to either use +# encryption for the standard port or to use a dedicated, additional port along with the unencrypted +# standard native_transport_port. +# Enabling client encryption and keeping native_transport_port_ssl disabled will use encryption +# for native_transport_port. Setting native_transport_port_ssl to a different value +# from native_transport_port will use encryption for native_transport_port_ssl while +# keeping native_transport_port unencrypted. +# native_transport_port_ssl: 9142 +# The maximum threads for handling requests when the native transport is used. +# This is similar to rpc_max_threads though the default differs slightly (and +# there is no native_transport_min_threads, idle threads will always be stopped +# after 30 seconds). +# native_transport_max_threads: 128 +# +# The maximum size of allowed frame. Frame (requests) larger than this will +# be rejected as invalid. The default is 256MB. If you're changing this parameter, +# you may want to adjust max_value_size_in_mb accordingly. This should be positive and less than 2048. +# native_transport_max_frame_size_in_mb: 256 + +# The maximum number of concurrent client connections. +# The default is -1, which means unlimited. +# native_transport_max_concurrent_connections: -1 + +# The maximum number of concurrent client connections per source ip. +# The default is -1, which means unlimited. +# native_transport_max_concurrent_connections_per_ip: -1 + +# Whether to start the thrift rpc server. +start_rpc: false + +# The address or interface to bind the Thrift RPC service and native transport +# server to. +# +# Set rpc_address OR rpc_interface, not both. +# +# Leaving rpc_address blank has the same effect as on listen_address +# (i.e. it will be based on the configured hostname of the node). +# +# Note that unlike listen_address, you can specify 0.0.0.0, but you must also +# set broadcast_rpc_address to a value other than 0.0.0.0. +# +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +rpc_address: 0.0.0.0 + +# Set rpc_address OR rpc_interface, not both. Interfaces must correspond +# to a single address, IP aliasing is not supported. +# rpc_interface: eth1 + +# If you choose to specify the interface by name and the interface has an ipv4 and an ipv6 address +# you can specify which should be chosen using rpc_interface_prefer_ipv6. If false the first ipv4 +# address will be used. If true the first ipv6 address will be used. Defaults to false preferring +# ipv4. If there is only one address it will be selected regardless of ipv4/ipv6. +# rpc_interface_prefer_ipv6: false + +# port for Thrift to listen for clients on +rpc_port: 9160 + +# RPC address to broadcast to drivers and other Cassandra nodes. This cannot +# be set to 0.0.0.0. If left blank, this will be set to the value of +# rpc_address. If rpc_address is set to 0.0.0.0, broadcast_rpc_address must +# be set. +broadcast_rpc_address: 172.17.0.2 + +# enable or disable keepalive on rpc/native connections +rpc_keepalive: true + +# Cassandra provides two out-of-the-box options for the RPC Server: +# +# sync +# One thread per thrift connection. For a very large number of clients, memory +# will be your limiting factor. On a 64 bit JVM, 180KB is the minimum stack size +# per thread, and that will correspond to your use of virtual memory (but physical memory +# may be limited depending on use of stack space). +# +# hsha +# Stands for "half synchronous, half asynchronous." All thrift clients are handled +# asynchronously using a small number of threads that does not vary with the amount +# of thrift clients (and thus scales well to many clients). The rpc requests are still +# synchronous (one thread per active request). If hsha is selected then it is essential +# that rpc_max_threads is changed from the default value of unlimited. +# +# The default is sync because on Windows hsha is about 30% slower. On Linux, +# sync/hsha performance is about the same, with hsha of course using less memory. +# +# Alternatively, can provide your own RPC server by providing the fully-qualified class name +# of an o.a.c.t.TServerFactory that can create an instance of it. +rpc_server_type: sync + +# Uncomment rpc_min|max_thread to set request pool size limits. +# +# Regardless of your choice of RPC server (see above), the number of maximum requests in the +# RPC thread pool dictates how many concurrent requests are possible (but if you are using the sync +# RPC server, it also dictates the number of clients that can be connected at all). +# +# The default is unlimited and thus provides no protection against clients overwhelming the server. You are +# encouraged to set a maximum that makes sense for you in production, but do keep in mind that +# rpc_max_threads represents the maximum number of client requests this server may execute concurrently. +# +# rpc_min_threads: 16 +# rpc_max_threads: 2048 + +# uncomment to set socket buffer sizes on rpc connections +# rpc_send_buff_size_in_bytes: +# rpc_recv_buff_size_in_bytes: + +# Uncomment to set socket buffer size for internode communication +# Note that when setting this, the buffer size is limited by net.core.wmem_max +# and when not setting it it is defined by net.ipv4.tcp_wmem +# See also: +# /proc/sys/net/core/wmem_max +# /proc/sys/net/core/rmem_max +# /proc/sys/net/ipv4/tcp_wmem +# /proc/sys/net/ipv4/tcp_wmem +# and 'man tcp' +# internode_send_buff_size_in_bytes: + +# Uncomment to set socket buffer size for internode communication +# Note that when setting this, the buffer size is limited by net.core.wmem_max +# and when not setting it it is defined by net.ipv4.tcp_wmem +# internode_recv_buff_size_in_bytes: + +# Frame size for thrift (maximum message length). +thrift_framed_transport_size_in_mb: 15 + +# Set to true to have Cassandra create a hard link to each sstable +# flushed or streamed locally in a backups/ subdirectory of the +# keyspace data. Removing these links is the operator's +# responsibility. +incremental_backups: false + +# Whether or not to take a snapshot before each compaction. Be +# careful using this option, since Cassandra won't clean up the +# snapshots for you. Mostly useful if you're paranoid when there +# is a data format change. +snapshot_before_compaction: false + +# Whether or not a snapshot is taken of the data before keyspace truncation +# or dropping of column families. The STRONGLY advised default of true +# should be used to provide data safety. If you set this flag to false, you will +# lose data on truncation or drop. +auto_snapshot: true + +# Granularity of the collation index of rows within a partition. +# Increase if your rows are large, or if you have a very large +# number of rows per partition. The competing goals are these: +# +# - a smaller granularity means more index entries are generated +# and looking up rows withing the partition by collation column +# is faster +# - but, Cassandra will keep the collation index in memory for hot +# rows (as part of the key cache), so a larger granularity means +# you can cache more hot rows +column_index_size_in_kb: 64 + +# Per sstable indexed key cache entries (the collation index in memory +# mentioned above) exceeding this size will not be held on heap. +# This means that only partition information is held on heap and the +# index entries are read from disk. +# +# Note that this size refers to the size of the +# serialized index information and not the size of the partition. +column_index_cache_size_in_kb: 2 + +# Number of simultaneous compactions to allow, NOT including +# validation "compactions" for anti-entropy repair. Simultaneous +# compactions can help preserve read performance in a mixed read/write +# workload, by mitigating the tendency of small sstables to accumulate +# during a single long running compactions. The default is usually +# fine and if you experience problems with compaction running too +# slowly or too fast, you should look at +# compaction_throughput_mb_per_sec first. +# +# concurrent_compactors defaults to the smaller of (number of disks, +# number of cores), with a minimum of 2 and a maximum of 8. +# +# If your data directories are backed by SSD, you should increase this +# to the number of cores. +#concurrent_compactors: 1 + +# Throttles compaction to the given total throughput across the entire +# system. The faster you insert data, the faster you need to compact in +# order to keep the sstable count down, but in general, setting this to +# 16 to 32 times the rate you are inserting data is more than sufficient. +# Setting this to 0 disables throttling. Note that this account for all types +# of compaction, including validation compaction. +compaction_throughput_mb_per_sec: 16 + +# When compacting, the replacement sstable(s) can be opened before they +# are completely written, and used in place of the prior sstables for +# any range that has been written. This helps to smoothly transfer reads +# between the sstables, reducing page cache churn and keeping hot rows hot +sstable_preemptive_open_interval_in_mb: 50 + +# Throttles all outbound streaming file transfers on this node to the +# given total throughput in Mbps. This is necessary because Cassandra does +# mostly sequential IO when streaming data during bootstrap or repair, which +# can lead to saturating the network connection and degrading rpc performance. +# When unset, the default is 200 Mbps or 25 MB/s. +# stream_throughput_outbound_megabits_per_sec: 200 + +# Throttles all streaming file transfer between the datacenters, +# this setting allows users to throttle inter dc stream throughput in addition +# to throttling all network stream traffic as configured with +# stream_throughput_outbound_megabits_per_sec +# When unset, the default is 200 Mbps or 25 MB/s +# inter_dc_stream_throughput_outbound_megabits_per_sec: 200 + +# How long the coordinator should wait for read operations to complete +read_request_timeout_in_ms: 5000 +# How long the coordinator should wait for seq or index scans to complete +range_request_timeout_in_ms: 10000 +# How long the coordinator should wait for writes to complete +write_request_timeout_in_ms: 2000 +# How long the coordinator should wait for counter writes to complete +counter_write_request_timeout_in_ms: 5000 +# How long a coordinator should continue to retry a CAS operation +# that contends with other proposals for the same row +cas_contention_timeout_in_ms: 1000 +# How long the coordinator should wait for truncates to complete +# (This can be much longer, because unless auto_snapshot is disabled +# we need to flush first so we can snapshot before removing the data.) +truncate_request_timeout_in_ms: 60000 +# The default timeout for other, miscellaneous operations +request_timeout_in_ms: 10000 + +# How long before a node logs slow queries. Select queries that take longer than +# this timeout to execute, will generate an aggregated log message, so that slow queries +# can be identified. Set this value to zero to disable slow query logging. +slow_query_log_timeout_in_ms: 500 + +# Enable operation timeout information exchange between nodes to accurately +# measure request timeouts. If disabled, replicas will assume that requests +# were forwarded to them instantly by the coordinator, which means that +# under overload conditions we will waste that much extra time processing +# already-timed-out requests. +# +# Warning: before enabling this property make sure to ntp is installed +# and the times are synchronized between the nodes. +cross_node_timeout: false + +# Set keep-alive period for streaming +# This node will send a keep-alive message periodically with this period. +# If the node does not receive a keep-alive message from the peer for +# 2 keep-alive cycles the stream session times out and fail +# Default value is 300s (5 minutes), which means stalled stream +# times out in 10 minutes by default +# streaming_keep_alive_period_in_secs: 300 + +# phi value that must be reached for a host to be marked down. +# most users should never need to adjust this. +# phi_convict_threshold: 8 + +# endpoint_snitch -- Set this to a class that implements +# IEndpointSnitch. The snitch has two functions: +# +# - it teaches Cassandra enough about your network topology to route +# requests efficiently +# - it allows Cassandra to spread replicas around your cluster to avoid +# correlated failures. It does this by grouping machines into +# "datacenters" and "racks." Cassandra will do its best not to have +# more than one replica on the same "rack" (which may not actually +# be a physical location) +# +# CASSANDRA WILL NOT ALLOW YOU TO SWITCH TO AN INCOMPATIBLE SNITCH +# ONCE DATA IS INSERTED INTO THE CLUSTER. This would cause data loss. +# This means that if you start with the default SimpleSnitch, which +# locates every node on "rack1" in "datacenter1", your only options +# if you need to add another datacenter are GossipingPropertyFileSnitch +# (and the older PFS). From there, if you want to migrate to an +# incompatible snitch like Ec2Snitch you can do it by adding new nodes +# under Ec2Snitch (which will locate them in a new "datacenter") and +# decommissioning the old ones. +# +# Out of the box, Cassandra provides: +# +# SimpleSnitch: +# Treats Strategy order as proximity. This can improve cache +# locality when disabling read repair. Only appropriate for +# single-datacenter deployments. +# +# GossipingPropertyFileSnitch +# This should be your go-to snitch for production use. The rack +# and datacenter for the local node are defined in +# cassandra-rackdc.properties and propagated to other nodes via +# gossip. If cassandra-topology.properties exists, it is used as a +# fallback, allowing migration from the PropertyFileSnitch. +# +# PropertyFileSnitch: +# Proximity is determined by rack and data center, which are +# explicitly configured in cassandra-topology.properties. +# +# Ec2Snitch: +# Appropriate for EC2 deployments in a single Region. Loads Region +# and Availability Zone information from the EC2 API. The Region is +# treated as the datacenter, and the Availability Zone as the rack. +# Only private IPs are used, so this will not work across multiple +# Regions. +# +# Ec2MultiRegionSnitch: +# Uses public IPs as broadcast_address to allow cross-region +# connectivity. (Thus, you should set seed addresses to the public +# IP as well.) You will need to open the storage_port or +# ssl_storage_port on the public IP firewall. (For intra-Region +# traffic, Cassandra will switch to the private IP after +# establishing a connection.) +# +# RackInferringSnitch: +# Proximity is determined by rack and data center, which are +# assumed to correspond to the 3rd and 2nd octet of each node's IP +# address, respectively. Unless this happens to match your +# deployment conventions, this is best used as an example of +# writing a custom Snitch class and is provided in that spirit. +# +# You can use a custom Snitch by setting this to the full class name +# of the snitch, which will be assumed to be on your classpath. +endpoint_snitch: SimpleSnitch + +# controls how often to perform the more expensive part of host score +# calculation +dynamic_snitch_update_interval_in_ms: 100 +# controls how often to reset all host scores, allowing a bad host to +# possibly recover +dynamic_snitch_reset_interval_in_ms: 600000 +# if set greater than zero and read_repair_chance is < 1.0, this will allow +# 'pinning' of replicas to hosts in order to increase cache capacity. +# The badness threshold will control how much worse the pinned host has to be +# before the dynamic snitch will prefer other replicas over it. This is +# expressed as a double which represents a percentage. Thus, a value of +# 0.2 means Cassandra would continue to prefer the static snitch values +# until the pinned host was 20% worse than the fastest. +dynamic_snitch_badness_threshold: 0.1 + +# request_scheduler -- Set this to a class that implements +# RequestScheduler, which will schedule incoming client requests +# according to the specific policy. This is useful for multi-tenancy +# with a single Cassandra cluster. +# NOTE: This is specifically for requests from the client and does +# not affect inter node communication. +# org.apache.cassandra.scheduler.NoScheduler - No scheduling takes place +# org.apache.cassandra.scheduler.RoundRobinScheduler - Round robin of +# client requests to a node with a separate queue for each +# request_scheduler_id. The scheduler is further customized by +# request_scheduler_options as described below. +request_scheduler: org.apache.cassandra.scheduler.NoScheduler + +# Scheduler Options vary based on the type of scheduler +# +# NoScheduler +# Has no options +# +# RoundRobin +# throttle_limit +# The throttle_limit is the number of in-flight +# requests per client. Requests beyond +# that limit are queued up until +# running requests can complete. +# The value of 80 here is twice the number of +# concurrent_reads + concurrent_writes. +# default_weight +# default_weight is optional and allows for +# overriding the default which is 1. +# weights +# Weights are optional and will default to 1 or the +# overridden default_weight. The weight translates into how +# many requests are handled during each turn of the +# RoundRobin, based on the scheduler id. +# +# request_scheduler_options: +# throttle_limit: 80 +# default_weight: 5 +# weights: +# Keyspace1: 1 +# Keyspace2: 5 + +# request_scheduler_id -- An identifier based on which to perform +# the request scheduling. Currently the only valid option is keyspace. +# request_scheduler_id: keyspace + +# Enable or disable inter-node encryption +# JVM defaults for supported SSL socket protocols and cipher suites can +# be replaced using custom encryption options. This is not recommended +# unless you have policies in place that dictate certain settings, or +# need to disable vulnerable ciphers or protocols in case the JVM cannot +# be updated. +# FIPS compliant settings can be configured at JVM level and should not +# involve changing encryption settings here: +# https://docs.oracle.com/javase/8/docs/technotes/guides/security/jsse/FIPS.html +# *NOTE* No custom encryption options are enabled at the moment +# The available internode options are : all, none, dc, rack +# +# If set to dc cassandra will encrypt the traffic between the DCs +# If set to rack cassandra will encrypt the traffic between the racks +# +# The passwords used in these options must match the passwords used when generating +# the keystore and truststore. For instructions on generating these files, see: +# http://download.oracle.com/javase/6/docs/technotes/guides/security/jsse/JSSERefGuide.html#CreateKeystore +# +server_encryption_options: + internode_encryption: none + keystore: conf/keystore + keystore_password: cassandra + truststore: conf/.truststore + truststore_password: cassandra + # More advanced defaults below: + # protocol: TLS + # algorithm: SunX509 + # store_type: JKS + # cipher_suites: [TLS_RSA_WITH_AES_128_CBC_SHA,TLS_RSA_WITH_AES_256_CBC_SHA,TLS_DHE_RSA_WITH_AES_128_CBC_SHA,TLS_DHE_RSA_WITH_AES_256_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA] + # require_client_auth: false + # require_endpoint_verification: false + +# enable or disable client/server encryption. +client_encryption_options: + enabled: true + # If enabled and optional is set to true encrypted and unencrypted connections are handled. + optional: false + keystore: /etc/cassandra/keystore.p12 + keystore_password: "cassandra" + require_client_auth: true + truststore: /etc/cassandra/truststore.p12 + truststore_password: "cassandra" + store_type: PKCS12 + # More advanced defaults below: + # protocol: TLS + # algorithm: SunX509 + # store_type: JKS + # cipher_suites: [TLS_RSA_WITH_AES_128_CBC_SHA,TLS_RSA_WITH_AES_256_CBC_SHA,TLS_DHE_RSA_WITH_AES_128_CBC_SHA,TLS_DHE_RSA_WITH_AES_256_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA] + +# internode_compression controls whether traffic between nodes is +# compressed. +# Can be: +# +# all +# all traffic is compressed +# +# dc +# traffic between different datacenters is compressed +# +# none +# nothing is compressed. +internode_compression: dc + +# Enable or disable tcp_nodelay for inter-dc communication. +# Disabling it will result in larger (but fewer) network packets being sent, +# reducing overhead from the TCP protocol itself, at the cost of increasing +# latency if you block for cross-datacenter responses. +inter_dc_tcp_nodelay: false + +# TTL for different trace types used during logging of the repair process. +tracetype_query_ttl: 86400 +tracetype_repair_ttl: 604800 + +# By default, Cassandra logs GC Pauses greater than 200 ms at INFO level +# This threshold can be adjusted to minimize logging if necessary +# gc_log_threshold_in_ms: 200 + +# If unset, all GC Pauses greater than gc_log_threshold_in_ms will log at +# INFO level +# UDFs (user defined functions) are disabled by default. +# As of Cassandra 3.0 there is a sandbox in place that should prevent execution of evil code. +enable_user_defined_functions: false + +# Enables scripted UDFs (JavaScript UDFs). +# Java UDFs are always enabled, if enable_user_defined_functions is true. +# Enable this option to be able to use UDFs with "language javascript" or any custom JSR-223 provider. +# This option has no effect, if enable_user_defined_functions is false. +enable_scripted_user_defined_functions: false + +# The default Windows kernel timer and scheduling resolution is 15.6ms for power conservation. +# Lowering this value on Windows can provide much tighter latency and better throughput, however +# some virtualized environments may see a negative performance impact from changing this setting +# below their system default. The sysinternals 'clockres' tool can confirm your system's default +# setting. +windows_timer_interval: 1 + + +# Enables encrypting data at-rest (on disk). Different key providers can be plugged in, but the default reads from +# a JCE-style keystore. A single keystore can hold multiple keys, but the one referenced by +# the "key_alias" is the only key that will be used for encrypt opertaions; previously used keys +# can still (and should!) be in the keystore and will be used on decrypt operations +# (to handle the case of key rotation). +# +# It is strongly recommended to download and install Java Cryptography Extension (JCE) +# Unlimited Strength Jurisdiction Policy Files for your version of the JDK. +# (current link: http://www.oracle.com/technetwork/java/javase/downloads/jce8-download-2133166.html) +# +# Currently, only the following file types are supported for transparent data encryption, although +# more are coming in future cassandra releases: commitlog, hints +transparent_data_encryption_options: + enabled: false + chunk_length_kb: 64 + cipher: AES/CBC/PKCS5Padding + key_alias: testing:1 + # CBC IV length for AES needs to be 16 bytes (which is also the default size) + # iv_length: 16 + key_provider: + - class_name: org.apache.cassandra.security.JKSKeyProvider + parameters: + - keystore: conf/keystore + keystore_password: cassandra + store_type: JCEKS + key_password: cassandra + + +##################### +# SAFETY THRESHOLDS # +##################### + +# When executing a scan, within or across a partition, we need to keep the +# tombstones seen in memory so we can return them to the coordinator, which +# will use them to make sure other replicas also know about the deleted rows. +# With workloads that generate a lot of tombstones, this can cause performance +# problems and even exaust the server heap. +# (http://www.datastax.com/dev/blog/cassandra-anti-patterns-queues-and-queue-like-datasets) +# Adjust the thresholds here if you understand the dangers and want to +# scan more tombstones anyway. These thresholds may also be adjusted at runtime +# using the StorageService mbean. +tombstone_warn_threshold: 1000 +tombstone_failure_threshold: 100000 + +# Log WARN on any multiple-partition batch size exceeding this value. 5kb per batch by default. +# Caution should be taken on increasing the size of this threshold as it can lead to node instability. +batch_size_warn_threshold_in_kb: 5 + +# Fail any multiple-partition batch exceeding this value. 50kb (10x warn threshold) by default. +batch_size_fail_threshold_in_kb: 50 + +# Log WARN on any batches not of type LOGGED than span across more partitions than this limit +unlogged_batch_across_partitions_warn_threshold: 10 + +# Log a warning when compacting partitions larger than this value +compaction_large_partition_warning_threshold_mb: 100 + +# GC Pauses greater than gc_warn_threshold_in_ms will be logged at WARN level +# Adjust the threshold based on your application throughput requirement +# By default, Cassandra logs GC Pauses greater than 200 ms at INFO level +gc_warn_threshold_in_ms: 1000 + +# Maximum size of any value in SSTables. Safety measure to detect SSTable corruption +# early. Any value size larger than this threshold will result into marking an SSTable +# as corrupted. This should be positive and less than 2048. +# max_value_size_in_mb: 256 + +# Back-pressure settings # +# If enabled, the coordinator will apply the back-pressure strategy specified below to each mutation +# sent to replicas, with the aim of reducing pressure on overloaded replicas. +back_pressure_enabled: false +# The back-pressure strategy applied. +# The default implementation, RateBasedBackPressure, takes three arguments: +# high ratio, factor, and flow type, and uses the ratio between incoming mutation responses and outgoing mutation requests. +# If below high ratio, outgoing mutations are rate limited according to the incoming rate decreased by the given factor; +# if above high ratio, the rate limiting is increased by the given factor; +# such factor is usually best configured between 1 and 10, use larger values for a faster recovery +# at the expense of potentially more dropped mutations; +# the rate limiting is applied according to the flow type: if FAST, it's rate limited at the speed of the fastest replica, +# if SLOW at the speed of the slowest one. +# New strategies can be added. Implementors need to implement org.apache.cassandra.net.BackpressureStrategy and +# provide a public constructor accepting a Map. +back_pressure_strategy: + - class_name: org.apache.cassandra.net.RateBasedBackPressure + parameters: + - high_ratio: 0.90 + factor: 5 + flow: FAST + +# Coalescing Strategies # +# Coalescing multiples messages turns out to significantly boost message processing throughput (think doubling or more). +# On bare metal, the floor for packet processing throughput is high enough that many applications won't notice, but in +# virtualized environments, the point at which an application can be bound by network packet processing can be +# surprisingly low compared to the throughput of task processing that is possible inside a VM. It's not that bare metal +# doesn't benefit from coalescing messages, it's that the number of packets a bare metal network interface can process +# is sufficient for many applications such that no load starvation is experienced even without coalescing. +# There are other benefits to coalescing network messages that are harder to isolate with a simple metric like messages +# per second. By coalescing multiple tasks together, a network thread can process multiple messages for the cost of one +# trip to read from a socket, and all the task submission work can be done at the same time reducing context switching +# and increasing cache friendliness of network message processing. +# See CASSANDRA-8692 for details. + +# Strategy to use for coalescing messages in OutboundTcpConnection. +# Can be fixed, movingaverage, timehorizon, disabled (default). +# You can also specify a subclass of CoalescingStrategies.CoalescingStrategy by name. +# otc_coalescing_strategy: DISABLED + +# How many microseconds to wait for coalescing. For fixed strategy this is the amount of time after the first +# message is received before it will be sent with any accompanying messages. For moving average this is the +# maximum amount of time that will be waited as well as the interval at which messages must arrive on average +# for coalescing to be enabled. +# otc_coalescing_window_us: 200 + +# Do not try to coalesce messages if we already got that many messages. This should be more than 2 and less than 128. +# otc_coalescing_enough_coalesced_messages: 8 + +# How many milliseconds to wait between two expiration runs on the backlog (queue) of the OutboundTcpConnection. +# Expiration is done if messages are piling up in the backlog. Droppable messages are expired to free the memory +# taken by expired messages. The interval should be between 0 and 1000, and in most installations the default value +# will be appropriate. A smaller value could potentially expire messages slightly sooner at the expense of more CPU +# time and queue contention while iterating the backlog of messages. +# An interval of 0 disables any wait time, which is the behavior of former Cassandra versions. +# +# otc_backlog_expiration_interval_ms: 200 diff --git a/modules/cassandra/src/test/resources/cassandra-ssl-configuration/keystore.p12 b/modules/cassandra/src/test/resources/cassandra-ssl-configuration/keystore.p12 new file mode 100644 index 00000000000..501e25cf967 Binary files /dev/null and b/modules/cassandra/src/test/resources/cassandra-ssl-configuration/keystore.p12 differ diff --git a/modules/cassandra/src/test/resources/cassandra-ssl-configuration/truststore.p12 b/modules/cassandra/src/test/resources/cassandra-ssl-configuration/truststore.p12 new file mode 100644 index 00000000000..57107fc3813 Binary files /dev/null and b/modules/cassandra/src/test/resources/cassandra-ssl-configuration/truststore.p12 differ diff --git a/modules/cassandra/src/test/resources/cassandra-test-configuration-example/cassandra.yaml b/modules/cassandra/src/test/resources/cassandra-test-configuration-example/cassandra.yaml new file mode 100644 index 00000000000..426dea64771 --- /dev/null +++ b/modules/cassandra/src/test/resources/cassandra-test-configuration-example/cassandra.yaml @@ -0,0 +1,1233 @@ +# Cassandra storage config YAML + +# NOTE: +# See http://wiki.apache.org/cassandra/StorageConfiguration for +# full explanations of configuration directives +# /NOTE + +# The name of the cluster. This is mainly used to prevent machines in +# one logical cluster from joining another. +cluster_name: 'Test Cluster Integration Test' + +# This defines the number of tokens randomly assigned to this node on the ring +# The more tokens, relative to other nodes, the larger the proportion of data +# that this node will store. You probably want all nodes to have the same number +# of tokens assuming they have equal hardware capability. +# +# If you leave this unspecified, Cassandra will use the default of 1 token for legacy compatibility, +# and will use the initial_token as described below. +# +# Specifying initial_token will override this setting on the node's initial start, +# on subsequent starts, this setting will apply even if initial token is set. +# +# If you already have a cluster with 1 token per node, and wish to migrate to +# multiple tokens per node, see http://wiki.apache.org/cassandra/Operations +num_tokens: 256 + +# Triggers automatic allocation of num_tokens tokens for this node. The allocation +# algorithm attempts to choose tokens in a way that optimizes replicated load over +# the nodes in the datacenter for the replication strategy used by the specified +# keyspace. +# +# The load assigned to each node will be close to proportional to its number of +# vnodes. +# +# Only supported with the Murmur3Partitioner. +# allocate_tokens_for_keyspace: KEYSPACE + +# initial_token allows you to specify tokens manually. While you can use it with +# vnodes (num_tokens > 1, above) -- in which case you should provide a +# comma-separated list -- it's primarily used when adding nodes to legacy clusters +# that do not have vnodes enabled. +# initial_token: + +# See http://wiki.apache.org/cassandra/HintedHandoff +# May either be "true" or "false" to enable globally +hinted_handoff_enabled: true + +# When hinted_handoff_enabled is true, a black list of data centers that will not +# perform hinted handoff +# hinted_handoff_disabled_datacenters: +# - DC1 +# - DC2 + +# this defines the maximum amount of time a dead host will have hints +# generated. After it has been dead this long, new hints for it will not be +# created until it has been seen alive and gone down again. +max_hint_window_in_ms: 10800000 # 3 hours + +# Maximum throttle in KBs per second, per delivery thread. This will be +# reduced proportionally to the number of nodes in the cluster. (If there +# are two nodes in the cluster, each delivery thread will use the maximum +# rate; if there are three, each will throttle to half of the maximum, +# since we expect two nodes to be delivering hints simultaneously.) +hinted_handoff_throttle_in_kb: 1024 + +# Number of threads with which to deliver hints; +# Consider increasing this number when you have multi-dc deployments, since +# cross-dc handoff tends to be slower +max_hints_delivery_threads: 2 + +# Directory where Cassandra should store hints. +# If not set, the default directory is $CASSANDRA_HOME/data/hints. +# hints_directory: /var/lib/cassandra/hints + +# How often hints should be flushed from the internal buffers to disk. +# Will *not* trigger fsync. +hints_flush_period_in_ms: 10000 + +# Maximum size for a single hints file, in megabytes. +max_hints_file_size_in_mb: 128 + +# Compression to apply to the hint files. If omitted, hints files +# will be written uncompressed. LZ4, Snappy, and Deflate compressors +# are supported. +#hints_compression: +# - class_name: LZ4Compressor +# parameters: +# - + +# Maximum throttle in KBs per second, total. This will be +# reduced proportionally to the number of nodes in the cluster. +batchlog_replay_throttle_in_kb: 1024 + +# Authentication backend, implementing IAuthenticator; used to identify users +# Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllAuthenticator, +# PasswordAuthenticator}. +# +# - AllowAllAuthenticator performs no checks - set it to disable authentication. +# - PasswordAuthenticator relies on username/password pairs to authenticate +# users. It keeps usernames and hashed passwords in system_auth.roles table. +# Please increase system_auth keyspace replication factor if you use this authenticator. +# If using PasswordAuthenticator, CassandraRoleManager must also be used (see below) +authenticator: AllowAllAuthenticator + +# Authorization backend, implementing IAuthorizer; used to limit access/provide permissions +# Out of the box, Cassandra provides org.apache.cassandra.auth.{AllowAllAuthorizer, +# CassandraAuthorizer}. +# +# - AllowAllAuthorizer allows any action to any user - set it to disable authorization. +# - CassandraAuthorizer stores permissions in system_auth.role_permissions table. Please +# increase system_auth keyspace replication factor if you use this authorizer. +authorizer: AllowAllAuthorizer + +# Part of the Authentication & Authorization backend, implementing IRoleManager; used +# to maintain grants and memberships between roles. +# Out of the box, Cassandra provides org.apache.cassandra.auth.CassandraRoleManager, +# which stores role information in the system_auth keyspace. Most functions of the +# IRoleManager require an authenticated login, so unless the configured IAuthenticator +# actually implements authentication, most of this functionality will be unavailable. +# +# - CassandraRoleManager stores role data in the system_auth keyspace. Please +# increase system_auth keyspace replication factor if you use this role manager. +role_manager: CassandraRoleManager + +# Validity period for roles cache (fetching granted roles can be an expensive +# operation depending on the role manager, CassandraRoleManager is one example) +# Granted roles are cached for authenticated sessions in AuthenticatedUser and +# after the period specified here, become eligible for (async) reload. +# Defaults to 2000, set to 0 to disable caching entirely. +# Will be disabled automatically for AllowAllAuthenticator. +roles_validity_in_ms: 2000 + +# Refresh interval for roles cache (if enabled). +# After this interval, cache entries become eligible for refresh. Upon next +# access, an async reload is scheduled and the old value returned until it +# completes. If roles_validity_in_ms is non-zero, then this must be +# also. +# Defaults to the same value as roles_validity_in_ms. +# roles_update_interval_in_ms: 2000 + +# Validity period for permissions cache (fetching permissions can be an +# expensive operation depending on the authorizer, CassandraAuthorizer is +# one example). Defaults to 2000, set to 0 to disable. +# Will be disabled automatically for AllowAllAuthorizer. +permissions_validity_in_ms: 2000 + +# Refresh interval for permissions cache (if enabled). +# After this interval, cache entries become eligible for refresh. Upon next +# access, an async reload is scheduled and the old value returned until it +# completes. If permissions_validity_in_ms is non-zero, then this must be +# also. +# Defaults to the same value as permissions_validity_in_ms. +# permissions_update_interval_in_ms: 2000 + +# Validity period for credentials cache. This cache is tightly coupled to +# the provided PasswordAuthenticator implementation of IAuthenticator. If +# another IAuthenticator implementation is configured, this cache will not +# be automatically used and so the following settings will have no effect. +# Please note, credentials are cached in their encrypted form, so while +# activating this cache may reduce the number of queries made to the +# underlying table, it may not bring a significant reduction in the +# latency of individual authentication attempts. +# Defaults to 2000, set to 0 to disable credentials caching. +credentials_validity_in_ms: 2000 + +# Refresh interval for credentials cache (if enabled). +# After this interval, cache entries become eligible for refresh. Upon next +# access, an async reload is scheduled and the old value returned until it +# completes. If credentials_validity_in_ms is non-zero, then this must be +# also. +# Defaults to the same value as credentials_validity_in_ms. +# credentials_update_interval_in_ms: 2000 + +# The partitioner is responsible for distributing groups of rows (by +# partition key) across nodes in the cluster. You should leave this +# alone for new clusters. The partitioner can NOT be changed without +# reloading all data, so when upgrading you should set this to the +# same partitioner you were already using. +# +# Besides Murmur3Partitioner, partitioners included for backwards +# compatibility include RandomPartitioner, ByteOrderedPartitioner, and +# OrderPreservingPartitioner. +# +partitioner: org.apache.cassandra.dht.Murmur3Partitioner + +# Directories where Cassandra should store data on disk. Cassandra +# will spread data evenly across them, subject to the granularity of +# the configured compaction strategy. +# If not set, the default directory is $CASSANDRA_HOME/data/data. +data_file_directories: + - /var/lib/cassandra/data + +# commit log. when running on magnetic HDD, this should be a +# separate spindle than the data directories. +# If not set, the default directory is $CASSANDRA_HOME/data/commitlog. +commitlog_directory: /var/lib/cassandra/commitlog + +# Enable / disable CDC functionality on a per-node basis. This modifies the logic used +# for write path allocation rejection (standard: never reject. cdc: reject Mutation +# containing a CDC-enabled table if at space limit in cdc_raw_directory). +cdc_enabled: false + +# CommitLogSegments are moved to this directory on flush if cdc_enabled: true and the +# segment contains mutations for a CDC-enabled table. This should be placed on a +# separate spindle than the data directories. If not set, the default directory is +# $CASSANDRA_HOME/data/cdc_raw. +# cdc_raw_directory: /var/lib/cassandra/cdc_raw + +# Policy for data disk failures: +# +# die +# shut down gossip and client transports and kill the JVM for any fs errors or +# single-sstable errors, so the node can be replaced. +# +# stop_paranoid +# shut down gossip and client transports even for single-sstable errors, +# kill the JVM for errors during startup. +# +# stop +# shut down gossip and client transports, leaving the node effectively dead, but +# can still be inspected via JMX, kill the JVM for errors during startup. +# +# best_effort +# stop using the failed disk and respond to requests based on +# remaining available sstables. This means you WILL see obsolete +# data at CL.ONE! +# +# ignore +# ignore fatal errors and let requests fail, as in pre-1.2 Cassandra +disk_failure_policy: stop + +# Policy for commit disk failures: +# +# die +# shut down gossip and Thrift and kill the JVM, so the node can be replaced. +# +# stop +# shut down gossip and Thrift, leaving the node effectively dead, but +# can still be inspected via JMX. +# +# stop_commit +# shutdown the commit log, letting writes collect but +# continuing to service reads, as in pre-2.0.5 Cassandra +# +# ignore +# ignore fatal errors and let the batches fail +commit_failure_policy: stop + +# Maximum size of the native protocol prepared statement cache +# +# Valid values are either "auto" (omitting the value) or a value greater 0. +# +# Note that specifying a too large value will result in long running GCs and possibly +# out-of-memory errors. Keep the value at a small fraction of the heap. +# +# If you constantly see "prepared statements discarded in the last minute because +# cache limit reached" messages, the first step is to investigate the root cause +# of these messages and check whether prepared statements are used correctly - +# i.e. use bind markers for variable parts. +# +# Do only change the default value, if you really have more prepared statements than +# fit in the cache. In most cases it is not necessary to change this value. +# Constantly re-preparing statements is a performance penalty. +# +# Default value ("auto") is 1/256th of the heap or 10MB, whichever is greater +prepared_statements_cache_size_mb: + +# Maximum size of the Thrift prepared statement cache +# +# If you do not use Thrift at all, it is safe to leave this value at "auto". +# +# See description of 'prepared_statements_cache_size_mb' above for more information. +# +# Default value ("auto") is 1/256th of the heap or 10MB, whichever is greater +thrift_prepared_statements_cache_size_mb: + +# Maximum size of the key cache in memory. +# +# Each key cache hit saves 1 seek and each row cache hit saves 2 seeks at the +# minimum, sometimes more. The key cache is fairly tiny for the amount of +# time it saves, so it's worthwhile to use it at large numbers. +# The row cache saves even more time, but must contain the entire row, +# so it is extremely space-intensive. It's best to only use the +# row cache if you have hot rows or static rows. +# +# NOTE: if you reduce the size, you may not get you hottest keys loaded on startup. +# +# Default value is empty to make it "auto" (min(5% of Heap (in MB), 100MB)). Set to 0 to disable key cache. +key_cache_size_in_mb: + +# Duration in seconds after which Cassandra should +# save the key cache. Caches are saved to saved_caches_directory as +# specified in this configuration file. +# +# Saved caches greatly improve cold-start speeds, and is relatively cheap in +# terms of I/O for the key cache. Row cache saving is much more expensive and +# has limited use. +# +# Default is 14400 or 4 hours. +key_cache_save_period: 14400 + +# Number of keys from the key cache to save +# Disabled by default, meaning all keys are going to be saved +# key_cache_keys_to_save: 100 + +# Row cache implementation class name. Available implementations: +# +# org.apache.cassandra.cache.OHCProvider +# Fully off-heap row cache implementation (default). +# +# org.apache.cassandra.cache.SerializingCacheProvider +# This is the row cache implementation available +# in previous releases of Cassandra. +# row_cache_class_name: org.apache.cassandra.cache.OHCProvider + +# Maximum size of the row cache in memory. +# Please note that OHC cache implementation requires some additional off-heap memory to manage +# the map structures and some in-flight memory during operations before/after cache entries can be +# accounted against the cache capacity. This overhead is usually small compared to the whole capacity. +# Do not specify more memory that the system can afford in the worst usual situation and leave some +# headroom for OS block level cache. Do never allow your system to swap. +# +# Default value is 0, to disable row caching. +row_cache_size_in_mb: 0 + +# Duration in seconds after which Cassandra should save the row cache. +# Caches are saved to saved_caches_directory as specified in this configuration file. +# +# Saved caches greatly improve cold-start speeds, and is relatively cheap in +# terms of I/O for the key cache. Row cache saving is much more expensive and +# has limited use. +# +# Default is 0 to disable saving the row cache. +row_cache_save_period: 0 + +# Number of keys from the row cache to save. +# Specify 0 (which is the default), meaning all keys are going to be saved +# row_cache_keys_to_save: 100 + +# Maximum size of the counter cache in memory. +# +# Counter cache helps to reduce counter locks' contention for hot counter cells. +# In case of RF = 1 a counter cache hit will cause Cassandra to skip the read before +# write entirely. With RF > 1 a counter cache hit will still help to reduce the duration +# of the lock hold, helping with hot counter cell updates, but will not allow skipping +# the read entirely. Only the local (clock, count) tuple of a counter cell is kept +# in memory, not the whole counter, so it's relatively cheap. +# +# NOTE: if you reduce the size, you may not get you hottest keys loaded on startup. +# +# Default value is empty to make it "auto" (min(2.5% of Heap (in MB), 50MB)). Set to 0 to disable counter cache. +# NOTE: if you perform counter deletes and rely on low gcgs, you should disable the counter cache. +counter_cache_size_in_mb: + +# Duration in seconds after which Cassandra should +# save the counter cache (keys only). Caches are saved to saved_caches_directory as +# specified in this configuration file. +# +# Default is 7200 or 2 hours. +counter_cache_save_period: 7200 + +# Number of keys from the counter cache to save +# Disabled by default, meaning all keys are going to be saved +# counter_cache_keys_to_save: 100 + +# saved caches +# If not set, the default directory is $CASSANDRA_HOME/data/saved_caches. +saved_caches_directory: /var/lib/cassandra/saved_caches + +# commitlog_sync may be either "periodic" or "batch." +# +# When in batch mode, Cassandra won't ack writes until the commit log +# has been fsynced to disk. It will wait +# commitlog_sync_batch_window_in_ms milliseconds between fsyncs. +# This window should be kept short because the writer threads will +# be unable to do extra work while waiting. (You may need to increase +# concurrent_writes for the same reason.) +# +# commitlog_sync: batch +# commitlog_sync_batch_window_in_ms: 2 +# +# the other option is "periodic" where writes may be acked immediately +# and the CommitLog is simply synced every commitlog_sync_period_in_ms +# milliseconds. +commitlog_sync: periodic +commitlog_sync_period_in_ms: 10000 + +# The size of the individual commitlog file segments. A commitlog +# segment may be archived, deleted, or recycled once all the data +# in it (potentially from each columnfamily in the system) has been +# flushed to sstables. +# +# The default size is 32, which is almost always fine, but if you are +# archiving commitlog segments (see commitlog_archiving.properties), +# then you probably want a finer granularity of archiving; 8 or 16 MB +# is reasonable. +# Max mutation size is also configurable via max_mutation_size_in_kb setting in +# cassandra.yaml. The default is half the size commitlog_segment_size_in_mb * 1024. +# This should be positive and less than 2048. +# +# NOTE: If max_mutation_size_in_kb is set explicitly then commitlog_segment_size_in_mb must +# be set to at least twice the size of max_mutation_size_in_kb / 1024 +# +commitlog_segment_size_in_mb: 32 + +# Compression to apply to the commit log. If omitted, the commit log +# will be written uncompressed. LZ4, Snappy, and Deflate compressors +# are supported. +# commitlog_compression: +# - class_name: LZ4Compressor +# parameters: +# - + +# any class that implements the SeedProvider interface and has a +# constructor that takes a Map of parameters will do. +seed_provider: + # Addresses of hosts that are deemed contact points. + # Cassandra nodes use this list of hosts to find each other and learn + # the topology of the ring. You must change this if you are running + # multiple nodes! + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + # seeds is actually a comma-delimited list of addresses. + # Ex: ",," + - seeds: "172.17.0.2" + +# For workloads with more data than can fit in memory, Cassandra's +# bottleneck will be reads that need to fetch data from +# disk. "concurrent_reads" should be set to (16 * number_of_drives) in +# order to allow the operations to enqueue low enough in the stack +# that the OS and drives can reorder them. Same applies to +# "concurrent_counter_writes", since counter writes read the current +# values before incrementing and writing them back. +# +# On the other hand, since writes are almost never IO bound, the ideal +# number of "concurrent_writes" is dependent on the number of cores in +# your system; (8 * number_of_cores) is a good rule of thumb. +concurrent_reads: 32 +concurrent_writes: 32 +concurrent_counter_writes: 32 + +# For materialized view writes, as there is a read involved, so this should +# be limited by the less of concurrent reads or concurrent writes. +concurrent_materialized_view_writes: 32 + +# Maximum memory to use for sstable chunk cache and buffer pooling. +# 32MB of this are reserved for pooling buffers, the rest is used as a +# cache that holds uncompressed sstable chunks. +# Defaults to the smaller of 1/4 of heap or 512MB. This pool is allocated off-heap, +# so is in addition to the memory allocated for heap. The cache also has on-heap +# overhead which is roughly 128 bytes per chunk (i.e. 0.2% of the reserved size +# if the default 64k chunk size is used). +# Memory is only allocated when needed. +# file_cache_size_in_mb: 512 + +# Flag indicating whether to allocate on or off heap when the sstable buffer +# pool is exhausted, that is when it has exceeded the maximum memory +# file_cache_size_in_mb, beyond which it will not cache buffers but allocate on request. + +# buffer_pool_use_heap_if_exhausted: true + +# The strategy for optimizing disk read +# Possible values are: +# ssd (for solid state disks, the default) +# spinning (for spinning disks) +# disk_optimization_strategy: ssd + +# Total permitted memory to use for memtables. Cassandra will stop +# accepting writes when the limit is exceeded until a flush completes, +# and will trigger a flush based on memtable_cleanup_threshold +# If omitted, Cassandra will set both to 1/4 the size of the heap. +# memtable_heap_space_in_mb: 2048 +# memtable_offheap_space_in_mb: 2048 + +# memtable_cleanup_threshold is deprecated. The default calculation +# is the only reasonable choice. See the comments on memtable_flush_writers +# for more information. +# +# Ratio of occupied non-flushing memtable size to total permitted size +# that will trigger a flush of the largest memtable. Larger mct will +# mean larger flushes and hence less compaction, but also less concurrent +# flush activity which can make it difficult to keep your disks fed +# under heavy write load. +# +# memtable_cleanup_threshold defaults to 1 / (memtable_flush_writers + 1) +# memtable_cleanup_threshold: 0.11 + +# Specify the way Cassandra allocates and manages memtable memory. +# Options are: +# +# heap_buffers +# on heap nio buffers +# +# offheap_buffers +# off heap (direct) nio buffers +# +# offheap_objects +# off heap objects +memtable_allocation_type: heap_buffers + +# Total space to use for commit logs on disk. +# +# If space gets above this value, Cassandra will flush every dirty CF +# in the oldest segment and remove it. So a small total commitlog space +# will tend to cause more flush activity on less-active columnfamilies. +# +# The default value is the smaller of 8192, and 1/4 of the total space +# of the commitlog volume. +# +# commitlog_total_space_in_mb: 8192 + +# This sets the number of memtable flush writer threads per disk +# as well as the total number of memtables that can be flushed concurrently. +# These are generally a combination of compute and IO bound. +# +# Memtable flushing is more CPU efficient than memtable ingest and a single thread +# can keep up with the ingest rate of a whole server on a single fast disk +# until it temporarily becomes IO bound under contention typically with compaction. +# At that point you need multiple flush threads. At some point in the future +# it may become CPU bound all the time. +# +# You can tell if flushing is falling behind using the MemtablePool.BlockedOnAllocation +# metric which should be 0, but will be non-zero if threads are blocked waiting on flushing +# to free memory. +# +# memtable_flush_writers defaults to two for a single data directory. +# This means that two memtables can be flushed concurrently to the single data directory. +# If you have multiple data directories the default is one memtable flushing at a time +# but the flush will use a thread per data directory so you will get two or more writers. +# +# Two is generally enough to flush on a fast disk [array] mounted as a single data directory. +# Adding more flush writers will result in smaller more frequent flushes that introduce more +# compaction overhead. +# +# There is a direct tradeoff between number of memtables that can be flushed concurrently +# and flush size and frequency. More is not better you just need enough flush writers +# to never stall waiting for flushing to free memory. +# +#memtable_flush_writers: 2 + +# Total space to use for change-data-capture logs on disk. +# +# If space gets above this value, Cassandra will throw WriteTimeoutException +# on Mutations including tables with CDC enabled. A CDCCompactor is responsible +# for parsing the raw CDC logs and deleting them when parsing is completed. +# +# The default value is the min of 4096 mb and 1/8th of the total space +# of the drive where cdc_raw_directory resides. +# cdc_total_space_in_mb: 4096 + +# When we hit our cdc_raw limit and the CDCCompactor is either running behind +# or experiencing backpressure, we check at the following interval to see if any +# new space for cdc-tracked tables has been made available. Default to 250ms +# cdc_free_space_check_interval_ms: 250 + +# A fixed memory pool size in MB for SSTable index summaries. If left +# empty, this will default to 5% of the heap size. If the memory usage of +# all index summaries exceeds this limit, SSTables with low read rates will +# shrink their index summaries in order to meet this limit. However, this +# is a best-effort process. In extreme conditions Cassandra may need to use +# more than this amount of memory. +index_summary_capacity_in_mb: + +# How frequently index summaries should be resampled. This is done +# periodically to redistribute memory from the fixed-size pool to sstables +# proportional their recent read rates. Setting to -1 will disable this +# process, leaving existing index summaries at their current sampling level. +index_summary_resize_interval_in_minutes: 60 + +# Whether to, when doing sequential writing, fsync() at intervals in +# order to force the operating system to flush the dirty +# buffers. Enable this to avoid sudden dirty buffer flushing from +# impacting read latencies. Almost always a good idea on SSDs; not +# necessarily on platters. +trickle_fsync: false +trickle_fsync_interval_in_kb: 10240 + +# TCP port, for commands and data +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +storage_port: 7000 + +# SSL port, for encrypted communication. Unused unless enabled in +# encryption_options +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +ssl_storage_port: 7001 + +# Address or interface to bind to and tell other Cassandra nodes to connect to. +# You _must_ change this if you want multiple nodes to be able to communicate! +# +# Set listen_address OR listen_interface, not both. +# +# Leaving it blank leaves it up to InetAddress.getLocalHost(). This +# will always do the Right Thing _if_ the node is properly configured +# (hostname, name resolution, etc), and the Right Thing is to use the +# address associated with the hostname (it might not be). +# +# Setting listen_address to 0.0.0.0 is always wrong. +# +listen_address: 172.17.0.2 + +# Set listen_address OR listen_interface, not both. Interfaces must correspond +# to a single address, IP aliasing is not supported. +# listen_interface: eth0 + +# If you choose to specify the interface by name and the interface has an ipv4 and an ipv6 address +# you can specify which should be chosen using listen_interface_prefer_ipv6. If false the first ipv4 +# address will be used. If true the first ipv6 address will be used. Defaults to false preferring +# ipv4. If there is only one address it will be selected regardless of ipv4/ipv6. +# listen_interface_prefer_ipv6: false + +# Address to broadcast to other Cassandra nodes +# Leaving this blank will set it to the same value as listen_address +broadcast_address: 172.17.0.2 + +# When using multiple physical network interfaces, set this +# to true to listen on broadcast_address in addition to +# the listen_address, allowing nodes to communicate in both +# interfaces. +# Ignore this property if the network configuration automatically +# routes between the public and private networks such as EC2. +# listen_on_broadcast_address: false + +# Internode authentication backend, implementing IInternodeAuthenticator; +# used to allow/disallow connections from peer nodes. +# internode_authenticator: org.apache.cassandra.auth.AllowAllInternodeAuthenticator + +# Whether to start the native transport server. +# Please note that the address on which the native transport is bound is the +# same as the rpc_address. The port however is different and specified below. +start_native_transport: true +# port for the CQL native transport to listen for clients on +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +native_transport_port: 9042 +# Enabling native transport encryption in client_encryption_options allows you to either use +# encryption for the standard port or to use a dedicated, additional port along with the unencrypted +# standard native_transport_port. +# Enabling client encryption and keeping native_transport_port_ssl disabled will use encryption +# for native_transport_port. Setting native_transport_port_ssl to a different value +# from native_transport_port will use encryption for native_transport_port_ssl while +# keeping native_transport_port unencrypted. +# native_transport_port_ssl: 9142 +# The maximum threads for handling requests when the native transport is used. +# This is similar to rpc_max_threads though the default differs slightly (and +# there is no native_transport_min_threads, idle threads will always be stopped +# after 30 seconds). +# native_transport_max_threads: 128 +# +# The maximum size of allowed frame. Frame (requests) larger than this will +# be rejected as invalid. The default is 256MB. If you're changing this parameter, +# you may want to adjust max_value_size_in_mb accordingly. This should be positive and less than 2048. +# native_transport_max_frame_size_in_mb: 256 + +# The maximum number of concurrent client connections. +# The default is -1, which means unlimited. +# native_transport_max_concurrent_connections: -1 + +# The maximum number of concurrent client connections per source ip. +# The default is -1, which means unlimited. +# native_transport_max_concurrent_connections_per_ip: -1 + +# Whether to start the thrift rpc server. +start_rpc: false + +# The address or interface to bind the Thrift RPC service and native transport +# server to. +# +# Set rpc_address OR rpc_interface, not both. +# +# Leaving rpc_address blank has the same effect as on listen_address +# (i.e. it will be based on the configured hostname of the node). +# +# Note that unlike listen_address, you can specify 0.0.0.0, but you must also +# set broadcast_rpc_address to a value other than 0.0.0.0. +# +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +rpc_address: 0.0.0.0 + +# Set rpc_address OR rpc_interface, not both. Interfaces must correspond +# to a single address, IP aliasing is not supported. +# rpc_interface: eth1 + +# If you choose to specify the interface by name and the interface has an ipv4 and an ipv6 address +# you can specify which should be chosen using rpc_interface_prefer_ipv6. If false the first ipv4 +# address will be used. If true the first ipv6 address will be used. Defaults to false preferring +# ipv4. If there is only one address it will be selected regardless of ipv4/ipv6. +# rpc_interface_prefer_ipv6: false + +# port for Thrift to listen for clients on +rpc_port: 9160 + +# RPC address to broadcast to drivers and other Cassandra nodes. This cannot +# be set to 0.0.0.0. If left blank, this will be set to the value of +# rpc_address. If rpc_address is set to 0.0.0.0, broadcast_rpc_address must +# be set. +broadcast_rpc_address: 172.17.0.2 + +# enable or disable keepalive on rpc/native connections +rpc_keepalive: true + +# Cassandra provides two out-of-the-box options for the RPC Server: +# +# sync +# One thread per thrift connection. For a very large number of clients, memory +# will be your limiting factor. On a 64 bit JVM, 180KB is the minimum stack size +# per thread, and that will correspond to your use of virtual memory (but physical memory +# may be limited depending on use of stack space). +# +# hsha +# Stands for "half synchronous, half asynchronous." All thrift clients are handled +# asynchronously using a small number of threads that does not vary with the amount +# of thrift clients (and thus scales well to many clients). The rpc requests are still +# synchronous (one thread per active request). If hsha is selected then it is essential +# that rpc_max_threads is changed from the default value of unlimited. +# +# The default is sync because on Windows hsha is about 30% slower. On Linux, +# sync/hsha performance is about the same, with hsha of course using less memory. +# +# Alternatively, can provide your own RPC server by providing the fully-qualified class name +# of an o.a.c.t.TServerFactory that can create an instance of it. +rpc_server_type: sync + +# Uncomment rpc_min|max_thread to set request pool size limits. +# +# Regardless of your choice of RPC server (see above), the number of maximum requests in the +# RPC thread pool dictates how many concurrent requests are possible (but if you are using the sync +# RPC server, it also dictates the number of clients that can be connected at all). +# +# The default is unlimited and thus provides no protection against clients overwhelming the server. You are +# encouraged to set a maximum that makes sense for you in production, but do keep in mind that +# rpc_max_threads represents the maximum number of client requests this server may execute concurrently. +# +# rpc_min_threads: 16 +# rpc_max_threads: 2048 + +# uncomment to set socket buffer sizes on rpc connections +# rpc_send_buff_size_in_bytes: +# rpc_recv_buff_size_in_bytes: + +# Uncomment to set socket buffer size for internode communication +# Note that when setting this, the buffer size is limited by net.core.wmem_max +# and when not setting it it is defined by net.ipv4.tcp_wmem +# See also: +# /proc/sys/net/core/wmem_max +# /proc/sys/net/core/rmem_max +# /proc/sys/net/ipv4/tcp_wmem +# /proc/sys/net/ipv4/tcp_wmem +# and 'man tcp' +# internode_send_buff_size_in_bytes: + +# Uncomment to set socket buffer size for internode communication +# Note that when setting this, the buffer size is limited by net.core.wmem_max +# and when not setting it it is defined by net.ipv4.tcp_wmem +# internode_recv_buff_size_in_bytes: + +# Frame size for thrift (maximum message length). +thrift_framed_transport_size_in_mb: 15 + +# Set to true to have Cassandra create a hard link to each sstable +# flushed or streamed locally in a backups/ subdirectory of the +# keyspace data. Removing these links is the operator's +# responsibility. +incremental_backups: false + +# Whether or not to take a snapshot before each compaction. Be +# careful using this option, since Cassandra won't clean up the +# snapshots for you. Mostly useful if you're paranoid when there +# is a data format change. +snapshot_before_compaction: false + +# Whether or not a snapshot is taken of the data before keyspace truncation +# or dropping of column families. The STRONGLY advised default of true +# should be used to provide data safety. If you set this flag to false, you will +# lose data on truncation or drop. +auto_snapshot: true + +# Granularity of the collation index of rows within a partition. +# Increase if your rows are large, or if you have a very large +# number of rows per partition. The competing goals are these: +# +# - a smaller granularity means more index entries are generated +# and looking up rows within the partition by collation column +# is faster +# - but, Cassandra will keep the collation index in memory for hot +# rows (as part of the key cache), so a larger granularity means +# you can cache more hot rows +column_index_size_in_kb: 64 + +# Per sstable indexed key cache entries (the collation index in memory +# mentioned above) exceeding this size will not be held on heap. +# This means that only partition information is held on heap and the +# index entries are read from disk. +# +# Note that this size refers to the size of the +# serialized index information and not the size of the partition. +column_index_cache_size_in_kb: 2 + +# Number of simultaneous compactions to allow, NOT including +# validation "compactions" for anti-entropy repair. Simultaneous +# compactions can help preserve read performance in a mixed read/write +# workload, by mitigating the tendency of small sstables to accumulate +# during a single long running compactions. The default is usually +# fine and if you experience problems with compaction running too +# slowly or too fast, you should look at +# compaction_throughput_mb_per_sec first. +# +# concurrent_compactors defaults to the smaller of (number of disks, +# number of cores), with a minimum of 2 and a maximum of 8. +# +# If your data directories are backed by SSD, you should increase this +# to the number of cores. +#concurrent_compactors: 1 + +# Throttles compaction to the given total throughput across the entire +# system. The faster you insert data, the faster you need to compact in +# order to keep the sstable count down, but in general, setting this to +# 16 to 32 times the rate you are inserting data is more than sufficient. +# Setting this to 0 disables throttling. Note that this account for all types +# of compaction, including validation compaction. +compaction_throughput_mb_per_sec: 16 + +# When compacting, the replacement sstable(s) can be opened before they +# are completely written, and used in place of the prior sstables for +# any range that has been written. This helps to smoothly transfer reads +# between the sstables, reducing page cache churn and keeping hot rows hot +sstable_preemptive_open_interval_in_mb: 50 + +# Throttles all outbound streaming file transfers on this node to the +# given total throughput in Mbps. This is necessary because Cassandra does +# mostly sequential IO when streaming data during bootstrap or repair, which +# can lead to saturating the network connection and degrading rpc performance. +# When unset, the default is 200 Mbps or 25 MB/s. +# stream_throughput_outbound_megabits_per_sec: 200 + +# Throttles all streaming file transfer between the datacenters, +# this setting allows users to throttle inter dc stream throughput in addition +# to throttling all network stream traffic as configured with +# stream_throughput_outbound_megabits_per_sec +# When unset, the default is 200 Mbps or 25 MB/s +# inter_dc_stream_throughput_outbound_megabits_per_sec: 200 + +# How long the coordinator should wait for read operations to complete +read_request_timeout_in_ms: 5000 +# How long the coordinator should wait for seq or index scans to complete +range_request_timeout_in_ms: 10000 +# How long the coordinator should wait for writes to complete +write_request_timeout_in_ms: 2000 +# How long the coordinator should wait for counter writes to complete +counter_write_request_timeout_in_ms: 5000 +# How long a coordinator should continue to retry a CAS operation +# that contends with other proposals for the same row +cas_contention_timeout_in_ms: 1000 +# How long the coordinator should wait for truncates to complete +# (This can be much longer, because unless auto_snapshot is disabled +# we need to flush first so we can snapshot before removing the data.) +truncate_request_timeout_in_ms: 60000 +# The default timeout for other, miscellaneous operations +request_timeout_in_ms: 10000 + +# How long before a node logs slow queries. Select queries that take longer than +# this timeout to execute, will generate an aggregated log message, so that slow queries +# can be identified. Set this value to zero to disable slow query logging. +slow_query_log_timeout_in_ms: 500 + +# Enable operation timeout information exchange between nodes to accurately +# measure request timeouts. If disabled, replicas will assume that requests +# were forwarded to them instantly by the coordinator, which means that +# under overload conditions we will waste that much extra time processing +# already-timed-out requests. +# +# Warning: before enabling this property make sure to ntp is installed +# and the times are synchronized between the nodes. +cross_node_timeout: false + +# Set keep-alive period for streaming +# This node will send a keep-alive message periodically with this period. +# If the node does not receive a keep-alive message from the peer for +# 2 keep-alive cycles the stream session times out and fail +# Default value is 300s (5 minutes), which means stalled stream +# times out in 10 minutes by default +# streaming_keep_alive_period_in_secs: 300 + +# phi value that must be reached for a host to be marked down. +# most users should never need to adjust this. +# phi_convict_threshold: 8 + +# endpoint_snitch -- Set this to a class that implements +# IEndpointSnitch. The snitch has two functions: +# +# - it teaches Cassandra enough about your network topology to route +# requests efficiently +# - it allows Cassandra to spread replicas around your cluster to avoid +# correlated failures. It does this by grouping machines into +# "datacenters" and "racks." Cassandra will do its best not to have +# more than one replica on the same "rack" (which may not actually +# be a physical location) +# +# CASSANDRA WILL NOT ALLOW YOU TO SWITCH TO AN INCOMPATIBLE SNITCH +# ONCE DATA IS INSERTED INTO THE CLUSTER. This would cause data loss. +# This means that if you start with the default SimpleSnitch, which +# locates every node on "rack1" in "datacenter1", your only options +# if you need to add another datacenter are GossipingPropertyFileSnitch +# (and the older PFS). From there, if you want to migrate to an +# incompatible snitch like Ec2Snitch you can do it by adding new nodes +# under Ec2Snitch (which will locate them in a new "datacenter") and +# decommissioning the old ones. +# +# Out of the box, Cassandra provides: +# +# SimpleSnitch: +# Treats Strategy order as proximity. This can improve cache +# locality when disabling read repair. Only appropriate for +# single-datacenter deployments. +# +# GossipingPropertyFileSnitch +# This should be your go-to snitch for production use. The rack +# and datacenter for the local node are defined in +# cassandra-rackdc.properties and propagated to other nodes via +# gossip. If cassandra-topology.properties exists, it is used as a +# fallback, allowing migration from the PropertyFileSnitch. +# +# PropertyFileSnitch: +# Proximity is determined by rack and data center, which are +# explicitly configured in cassandra-topology.properties. +# +# Ec2Snitch: +# Appropriate for EC2 deployments in a single Region. Loads Region +# and Availability Zone information from the EC2 API. The Region is +# treated as the datacenter, and the Availability Zone as the rack. +# Only private IPs are used, so this will not work across multiple +# Regions. +# +# Ec2MultiRegionSnitch: +# Uses public IPs as broadcast_address to allow cross-region +# connectivity. (Thus, you should set seed addresses to the public +# IP as well.) You will need to open the storage_port or +# ssl_storage_port on the public IP firewall. (For intra-Region +# traffic, Cassandra will switch to the private IP after +# establishing a connection.) +# +# RackInferringSnitch: +# Proximity is determined by rack and data center, which are +# assumed to correspond to the 3rd and 2nd octet of each node's IP +# address, respectively. Unless this happens to match your +# deployment conventions, this is best used as an example of +# writing a custom Snitch class and is provided in that spirit. +# +# You can use a custom Snitch by setting this to the full class name +# of the snitch, which will be assumed to be on your classpath. +endpoint_snitch: SimpleSnitch + +# controls how often to perform the more expensive part of host score +# calculation +dynamic_snitch_update_interval_in_ms: 100 +# controls how often to reset all host scores, allowing a bad host to +# possibly recover +dynamic_snitch_reset_interval_in_ms: 600000 +# if set greater than zero and read_repair_chance is < 1.0, this will allow +# 'pinning' of replicas to hosts in order to increase cache capacity. +# The badness threshold will control how much worse the pinned host has to be +# before the dynamic snitch will prefer other replicas over it. This is +# expressed as a double which represents a percentage. Thus, a value of +# 0.2 means Cassandra would continue to prefer the static snitch values +# until the pinned host was 20% worse than the fastest. +dynamic_snitch_badness_threshold: 0.1 + +# request_scheduler -- Set this to a class that implements +# RequestScheduler, which will schedule incoming client requests +# according to the specific policy. This is useful for multi-tenancy +# with a single Cassandra cluster. +# NOTE: This is specifically for requests from the client and does +# not affect inter node communication. +# org.apache.cassandra.scheduler.NoScheduler - No scheduling takes place +# org.apache.cassandra.scheduler.RoundRobinScheduler - Round robin of +# client requests to a node with a separate queue for each +# request_scheduler_id. The scheduler is further customized by +# request_scheduler_options as described below. +request_scheduler: org.apache.cassandra.scheduler.NoScheduler + +# Scheduler Options vary based on the type of scheduler +# +# NoScheduler +# Has no options +# +# RoundRobin +# throttle_limit +# The throttle_limit is the number of in-flight +# requests per client. Requests beyond +# that limit are queued up until +# running requests can complete. +# The value of 80 here is twice the number of +# concurrent_reads + concurrent_writes. +# default_weight +# default_weight is optional and allows for +# overriding the default which is 1. +# weights +# Weights are optional and will default to 1 or the +# overridden default_weight. The weight translates into how +# many requests are handled during each turn of the +# RoundRobin, based on the scheduler id. +# +# request_scheduler_options: +# throttle_limit: 80 +# default_weight: 5 +# weights: +# Keyspace1: 1 +# Keyspace2: 5 + +# request_scheduler_id -- An identifier based on which to perform +# the request scheduling. Currently the only valid option is keyspace. +# request_scheduler_id: keyspace + +# Enable or disable inter-node encryption +# JVM defaults for supported SSL socket protocols and cipher suites can +# be replaced using custom encryption options. This is not recommended +# unless you have policies in place that dictate certain settings, or +# need to disable vulnerable ciphers or protocols in case the JVM cannot +# be updated. +# FIPS compliant settings can be configured at JVM level and should not +# involve changing encryption settings here: +# https://docs.oracle.com/javase/8/docs/technotes/guides/security/jsse/FIPS.html +# *NOTE* No custom encryption options are enabled at the moment +# The available internode options are : all, none, dc, rack +# +# If set to dc cassandra will encrypt the traffic between the DCs +# If set to rack cassandra will encrypt the traffic between the racks +# +# The passwords used in these options must match the passwords used when generating +# the keystore and truststore. For instructions on generating these files, see: +# http://download.oracle.com/javase/6/docs/technotes/guides/security/jsse/JSSERefGuide.html#CreateKeystore +# +server_encryption_options: + internode_encryption: none + keystore: conf/.keystore + keystore_password: cassandra + truststore: conf/.truststore + truststore_password: cassandra + # More advanced defaults below: + # protocol: TLS + # algorithm: SunX509 + # store_type: JKS + # cipher_suites: [TLS_RSA_WITH_AES_128_CBC_SHA,TLS_RSA_WITH_AES_256_CBC_SHA,TLS_DHE_RSA_WITH_AES_128_CBC_SHA,TLS_DHE_RSA_WITH_AES_256_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA] + # require_client_auth: false + # require_endpoint_verification: false + +# enable or disable client/server encryption. +client_encryption_options: + enabled: false + # If enabled and optional is set to true encrypted and unencrypted connections are handled. + optional: false + keystore: conf/.keystore + keystore_password: cassandra + # require_client_auth: false + # Set trustore and truststore_password if require_client_auth is true + # truststore: conf/.truststore + # truststore_password: cassandra + # More advanced defaults below: + # protocol: TLS + # algorithm: SunX509 + # store_type: JKS + # cipher_suites: [TLS_RSA_WITH_AES_128_CBC_SHA,TLS_RSA_WITH_AES_256_CBC_SHA,TLS_DHE_RSA_WITH_AES_128_CBC_SHA,TLS_DHE_RSA_WITH_AES_256_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA] + +# internode_compression controls whether traffic between nodes is +# compressed. +# Can be: +# +# all +# all traffic is compressed +# +# dc +# traffic between different datacenters is compressed +# +# none +# nothing is compressed. +internode_compression: dc + +# Enable or disable tcp_nodelay for inter-dc communication. +# Disabling it will result in larger (but fewer) network packets being sent, +# reducing overhead from the TCP protocol itself, at the cost of increasing +# latency if you block for cross-datacenter responses. +inter_dc_tcp_nodelay: false + +# TTL for different trace types used during logging of the repair process. +tracetype_query_ttl: 86400 +tracetype_repair_ttl: 604800 + +# By default, Cassandra logs GC Pauses greater than 200 ms at INFO level +# This threshold can be adjusted to minimize logging if necessary +# gc_log_threshold_in_ms: 200 + +# If unset, all GC Pauses greater than gc_log_threshold_in_ms will log at +# INFO level +# UDFs (user defined functions) are disabled by default. +# As of Cassandra 3.0 there is a sandbox in place that should prevent execution of evil code. +enable_user_defined_functions: false + +# Enables scripted UDFs (JavaScript UDFs). +# Java UDFs are always enabled, if enable_user_defined_functions is true. +# Enable this option to be able to use UDFs with "language javascript" or any custom JSR-223 provider. +# This option has no effect, if enable_user_defined_functions is false. +enable_scripted_user_defined_functions: false + +# The default Windows kernel timer and scheduling resolution is 15.6ms for power conservation. +# Lowering this value on Windows can provide much tighter latency and better throughput, however +# some virtualized environments may see a negative performance impact from changing this setting +# below their system default. The sysinternals 'clockres' tool can confirm your system's default +# setting. +windows_timer_interval: 1 + + +# Enables encrypting data at-rest (on disk). Different key providers can be plugged in, but the default reads from +# a JCE-style keystore. A single keystore can hold multiple keys, but the one referenced by +# the "key_alias" is the only key that will be used for encrypt operations; previously used keys +# can still (and should!) be in the keystore and will be used on decrypt operations +# (to handle the case of key rotation). +# +# It is strongly recommended to download and install Java Cryptography Extension (JCE) +# Unlimited Strength Jurisdiction Policy Files for your version of the JDK. +# (current link: http://www.oracle.com/technetwork/java/javase/downloads/jce8-download-2133166.html) +# +# Currently, only the following file types are supported for transparent data encryption, although +# more are coming in future cassandra releases: commitlog, hints +transparent_data_encryption_options: + enabled: false + chunk_length_kb: 64 + cipher: AES/CBC/PKCS5Padding + key_alias: testing:1 + # CBC IV length for AES needs to be 16 bytes (which is also the default size) + # iv_length: 16 + key_provider: + - class_name: org.apache.cassandra.security.JKSKeyProvider + parameters: + - keystore: conf/.keystore + keystore_password: cassandra + store_type: JCEKS + key_password: cassandra + + +##################### +# SAFETY THRESHOLDS # +##################### + +# When executing a scan, within or across a partition, we need to keep the +# tombstones seen in memory so we can return them to the coordinator, which +# will use them to make sure other replicas also know about the deleted rows. +# With workloads that generate a lot of tombstones, this can cause performance +# problems and even exhaust the server heap. +# (http://www.datastax.com/dev/blog/cassandra-anti-patterns-queues-and-queue-like-datasets) +# Adjust the thresholds here if you understand the dangers and want to +# scan more tombstones anyway. These thresholds may also be adjusted at runtime +# using the StorageService mbean. +tombstone_warn_threshold: 1000 +tombstone_failure_threshold: 100000 + +# Log WARN on any multiple-partition batch size exceeding this value. 5kb per batch by default. +# Caution should be taken on increasing the size of this threshold as it can lead to node instability. +batch_size_warn_threshold_in_kb: 5 + +# Fail any multiple-partition batch exceeding this value. 50kb (10x warn threshold) by default. +batch_size_fail_threshold_in_kb: 50 + +# Log WARN on any batches not of type LOGGED than span across more partitions than this limit +unlogged_batch_across_partitions_warn_threshold: 10 + +# Log a warning when compacting partitions larger than this value +compaction_large_partition_warning_threshold_mb: 100 + +# GC Pauses greater than gc_warn_threshold_in_ms will be logged at WARN level +# Adjust the threshold based on your application throughput requirement +# By default, Cassandra logs GC Pauses greater than 200 ms at INFO level +gc_warn_threshold_in_ms: 1000 + +# Maximum size of any value in SSTables. Safety measure to detect SSTable corruption +# early. Any value size larger than this threshold will result into marking an SSTable +# as corrupted. This should be positive and less than 2048. +# max_value_size_in_mb: 256 + +# Back-pressure settings # +# If enabled, the coordinator will apply the back-pressure strategy specified below to each mutation +# sent to replicas, with the aim of reducing pressure on overloaded replicas. +back_pressure_enabled: false +# The back-pressure strategy applied. +# The default implementation, RateBasedBackPressure, takes three arguments: +# high ratio, factor, and flow type, and uses the ratio between incoming mutation responses and outgoing mutation requests. +# If below high ratio, outgoing mutations are rate limited according to the incoming rate decreased by the given factor; +# if above high ratio, the rate limiting is increased by the given factor; +# such factor is usually best configured between 1 and 10, use larger values for a faster recovery +# at the expense of potentially more dropped mutations; +# the rate limiting is applied according to the flow type: if FAST, it's rate limited at the speed of the fastest replica, +# if SLOW at the speed of the slowest one. +# New strategies can be added. Implementors need to implement org.apache.cassandra.net.BackpressureStrategy and +# provide a public constructor accepting a Map. +back_pressure_strategy: + - class_name: org.apache.cassandra.net.RateBasedBackPressure + parameters: + - high_ratio: 0.90 + factor: 5 + flow: FAST + +# Coalescing Strategies # +# Coalescing multiples messages turns out to significantly boost message processing throughput (think doubling or more). +# On bare metal, the floor for packet processing throughput is high enough that many applications won't notice, but in +# virtualized environments, the point at which an application can be bound by network packet processing can be +# surprisingly low compared to the throughput of task processing that is possible inside a VM. It's not that bare metal +# doesn't benefit from coalescing messages, it's that the number of packets a bare metal network interface can process +# is sufficient for many applications such that no load starvation is experienced even without coalescing. +# There are other benefits to coalescing network messages that are harder to isolate with a simple metric like messages +# per second. By coalescing multiple tasks together, a network thread can process multiple messages for the cost of one +# trip to read from a socket, and all the task submission work can be done at the same time reducing context switching +# and increasing cache friendliness of network message processing. +# See CASSANDRA-8692 for details. + +# Strategy to use for coalescing messages in OutboundTcpConnection. +# Can be fixed, movingaverage, timehorizon, disabled (default). +# You can also specify a subclass of CoalescingStrategies.CoalescingStrategy by name. +# otc_coalescing_strategy: DISABLED + +# How many microseconds to wait for coalescing. For fixed strategy this is the amount of time after the first +# message is received before it will be sent with any accompanying messages. For moving average this is the +# maximum amount of time that will be waited as well as the interval at which messages must arrive on average +# for coalescing to be enabled. +# otc_coalescing_window_us: 200 + +# Do not try to coalesce messages if we already got that many messages. This should be more than 2 and less than 128. +# otc_coalescing_enough_coalesced_messages: 8 + +# How many milliseconds to wait between two expiration runs on the backlog (queue) of the OutboundTcpConnection. +# Expiration is done if messages are piling up in the backlog. Droppable messages are expired to free the memory +# taken by expired messages. The interval should be between 0 and 1000, and in most installations the default value +# will be appropriate. A smaller value could potentially expire messages slightly sooner at the expense of more CPU +# time and queue contention while iterating the backlog of messages. +# An interval of 0 disables any wait time, which is the behavior of former Cassandra versions. +# +# otc_backlog_expiration_interval_ms: 200 \ No newline at end of file diff --git a/modules/cassandra/src/test/resources/client-ssl/cassandra.cer.pem b/modules/cassandra/src/test/resources/client-ssl/cassandra.cer.pem new file mode 100644 index 00000000000..bafaa00317b --- /dev/null +++ b/modules/cassandra/src/test/resources/client-ssl/cassandra.cer.pem @@ -0,0 +1,26 @@ +Bag Attributes + friendlyName: localhost + localKeyID: 54 69 6D 65 20 31 37 32 39 33 34 38 39 36 38 31 31 39 +subject=C = None, L = None, O = Testcontainers, OU = Testcontainers, CN = localhost +issuer=C = None, L = None, O = Testcontainers, OU = Testcontainers, CN = localhost +-----BEGIN CERTIFICATE----- +MIIDbjCCAlagAwIBAgIJAKCVIipuH03/MA0GCSqGSIb3DQEBCwUAMGQxDTALBgNV +BAYTBE5vbmUxDTALBgNVBAcTBE5vbmUxFzAVBgNVBAoTDlRlc3Rjb250YWluZXJz +MRcwFQYDVQQLEw5UZXN0Y29udGFpbmVyczESMBAGA1UEAxMJbG9jYWxob3N0MCAX +DTI0MTAxOTE0NDIwOFoYDzIxMjQwOTI1MTQ0MjA4WjBkMQ0wCwYDVQQGEwROb25l +MQ0wCwYDVQQHEwROb25lMRcwFQYDVQQKEw5UZXN0Y29udGFpbmVyczEXMBUGA1UE +CxMOVGVzdGNvbnRhaW5lcnMxEjAQBgNVBAMTCWxvY2FsaG9zdDCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBALocrhrM1gYB/pF/qlDY+eFQZ9L8SMgCmn+I +mgx/UbKqJwLp5wYuoW/PA4RwraFPkimf5CAE2kpBGcJu/Qzyp0fJZlBXpmkDJVrG +pRbYz5mN4CrXNliYfAC1RzxvTT1tOjiDkk9kHVfs5nMVb9e2kq6tQEItflhlPzdD +FOe0pY2XBX2stcQ6URRkK5buyPeLhnTrKMfLWEWKKKzSQGen+lbtBURZzkpmK88q +qjLqqaZusXP6QlRVLqMADjQf7aXLi0A/fIhVrq1amqqiApJbijT0LP48DvS8DQQL +jNKkQ17vMClMmXusU5IgJMlXfGEzeTNUI56wHGYUdE69FTGFvZECAwEAAaMhMB8w +HQYDVR0OBBYEFNsvIE+IgkE0aTc+1MI7hpPQL2ZEMA0GCSqGSIb3DQEBCwUAA4IB +AQC4U/tGPuRS3m/r1p3aAq0D88UGg6oKHwqe3re3xrFAv9y+Y3M+FXyh5w/yMCAr +PcVo6Pef3hEjwc9wDuQoIcQ9eRZtYI1RnhkkuC8TZRk1KGKg9Lj4Zzbse7FfK92Z +DUYgIVyhC/YkeEDwTiZI8WxhbglozNg5Ygw+qLK4rYmk+X/NgdfdQHocuJ3Jwqqx +eYz0m2RUMhzxEI2z9jQr3DgjNkYrphLzaVXmO4MovzXx3DNeC8ADot9PGmaz24rl +RDeSWynxbgqzdXGHxtyR0LY1k+Y+5wqU28L90D0o3ZtaMBnK+Ft2AP2zpbtgr8rR +sf12uPyRUPzJQ46KNpjy4HN6 +-----END CERTIFICATE----- diff --git a/modules/cassandra/src/test/resources/client-ssl/cassandra.key.pem b/modules/cassandra/src/test/resources/client-ssl/cassandra.key.pem new file mode 100644 index 00000000000..4d291cf8852 --- /dev/null +++ b/modules/cassandra/src/test/resources/client-ssl/cassandra.key.pem @@ -0,0 +1,32 @@ +Bag Attributes + friendlyName: localhost + localKeyID: 54 69 6D 65 20 31 37 32 39 33 34 38 39 36 38 31 31 39 +Key Attributes: +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC6HK4azNYGAf6R +f6pQ2PnhUGfS/EjIApp/iJoMf1GyqicC6ecGLqFvzwOEcK2hT5Ipn+QgBNpKQRnC +bv0M8qdHyWZQV6ZpAyVaxqUW2M+ZjeAq1zZYmHwAtUc8b009bTo4g5JPZB1X7OZz +FW/XtpKurUBCLX5YZT83QxTntKWNlwV9rLXEOlEUZCuW7sj3i4Z06yjHy1hFiiis +0kBnp/pW7QVEWc5KZivPKqoy6qmmbrFz+kJUVS6jAA40H+2ly4tAP3yIVa6tWpqq +ogKSW4o09Cz+PA70vA0EC4zSpENe7zApTJl7rFOSICTJV3xhM3kzVCOesBxmFHRO +vRUxhb2RAgMBAAECggEACqD5e7C+Rr8oz+jS/z8FAxsmcsqgFXW6NEjG4EPWx89a +RWfthVFDov3XNsizzp/OulXWH2xnhkOyU7cm+Ia7JI+Z9w8Qz+dM5AkVA8Y23o9X +TnSjNx57DODnEP21eZAzxpp50DlPFU02pzsbYhE2AbsFp0HirB9MI70CN24xR9hP +i1zPgO7FVnLvn4INqVKgcV4vXlxvgDEvO4Myc1WoJXkyPCCObvEflBBWr2QwQfKT +T2qjCJWv/P2PJGFaZbOrEvOHZjprSid4/n9gbQrodGoChhjiZT//l19Ay+7eJkUc +yiiSK4u3fF4YPH9+CVpRQ94PHFe+0kQVvf+VGX/iqQKBgQDp5umTcZoho/KQzOd/ +pA8fgnzbipEl5ep2MHB98cqEQ93eFv4l/bBehDQ1WvmFJY8SIzU4EQotpnCZd4Vb +KH9PE8tsTRvw2cYbBuD751boLVaBn8wxtlTkFrN/CyAtV7w7AG0dXnDKushJx1NN +8AgvSr0X4hf0AKIWGtVteoX7qwKBgQDLse7Ze5dNbG48CpBGqQS4wFkTSEs5QKI5 +68JXEQoCmJb06O7rxj4f5CALv3pReP3nrl5+kLmT8O+yU5C5dXf06k/z4GDJ6Esm +8XTEfB2Ca+kI2RLyMRRXPA2nEunbSsyk1AVo2GeRJxG4TaDbu2zTkUBAEyCuwarW +OMsuYodPswKBgQCZc/kB1qH8OAdHoGawgv24+m7Xycz4RCLSb20d86edprjEn+kV +G56+I5Xs+0aAZ+e5Sof7xJIc6Pkudg9zgtojEyV+ZAhUt0sVKCoqmdeWc0gxupjI +dIq1KX+RdccieFDxlJIBlpgBKRGF9dNdaoC0JiBwrtBwMIomXmxvatbECQKBgQCS +X1xZn/xLwJ0+PAENJauk72OS/aJAk/d/U7ElS7M7xlbDyxbVCnHeDNoSVxgYr68U +6zIwFOOmMb6tEGuxOX5n2nB1uUkUDf7jDyNvhhjWfaDJoOOCck5BmX/eDTNLR+bi +kxEIFGnn3oFXRUFQZNCA/6GB6bzUl4qhwdIPlPHTDQKBgQCgztlUF5IOJFKMjVlY +yoA/7+b5zwrh8Y2+SLzF/HLah85AuHxsgdTuQh+HLKSwJejKCT95BSRJO/kV0XCR +KZGStqETpEH/2AJkxpjt0FZxtIQdnyTargbiipe4JzI3iCTLtfN5C9Pn3ZJ9giap +B5uQm4762aH2jw1kKFegHlIgJg== +-----END PRIVATE KEY----- diff --git a/modules/cassandra/src/test/resources/initial-with-error.cql b/modules/cassandra/src/test/resources/initial-with-error.cql new file mode 100644 index 00000000000..4f0e11d721b --- /dev/null +++ b/modules/cassandra/src/test/resources/initial-with-error.cql @@ -0,0 +1,6 @@ +CREATE KEYSPACE keySpaceTest WITH replication = {'class': 'SimpleStrategy', 'replication_factor' : 1}; + +USE keySpaceTest; + +/* The following statement contains an error (missing primary key) on purpose, do not fix it! */ +CREATE TABLE catalog_category (id bigint); diff --git a/modules/cassandra/src/test/resources/initial.cql b/modules/cassandra/src/test/resources/initial.cql new file mode 100644 index 00000000000..2caad3746f8 --- /dev/null +++ b/modules/cassandra/src/test/resources/initial.cql @@ -0,0 +1,7 @@ +CREATE KEYSPACE keySpaceTest WITH replication = {'class': 'SimpleStrategy', 'replication_factor' : 1}; + +USE keySpaceTest; + +CREATE TABLE catalog_category (id bigint primary key, name text); + +INSERT INTO catalog_category (id, name) VALUES (1, 'test_category'); \ No newline at end of file diff --git a/modules/cassandra/src/test/resources/logback-test.xml b/modules/cassandra/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/cassandra/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/chromadb/build.gradle b/modules/chromadb/build.gradle new file mode 100644 index 00000000000..ec41def56df --- /dev/null +++ b/modules/chromadb/build.gradle @@ -0,0 +1,7 @@ +description = "Testcontainers :: ChromaDB" + +dependencies { + api project(':testcontainers') + + testImplementation 'io.rest-assured:rest-assured:5.5.7' +} diff --git a/modules/chromadb/src/main/java/org/testcontainers/chromadb/ChromaDBContainer.java b/modules/chromadb/src/main/java/org/testcontainers/chromadb/ChromaDBContainer.java new file mode 100644 index 00000000000..af6c3df33fc --- /dev/null +++ b/modules/chromadb/src/main/java/org/testcontainers/chromadb/ChromaDBContainer.java @@ -0,0 +1,56 @@ +package org.testcontainers.chromadb; + +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation of ChromaDB. + *

+ * Supported images: {@code chromadb/chroma}, {@code ghcr.io/chroma-core/chroma} + *

+ * Exposed ports: 8000 + */ +@Slf4j +public class ChromaDBContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_DOCKER_IMAGE = DockerImageName.parse("chromadb/chroma"); + + private static final DockerImageName GHCR_DOCKER_IMAGE = DockerImageName.parse("ghcr.io/chroma-core/chroma"); + + public ChromaDBContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public ChromaDBContainer(DockerImageName dockerImageName) { + this(dockerImageName, isVersion2(dockerImageName.getVersionPart())); + } + + public ChromaDBContainer(DockerImageName dockerImageName, boolean isVersion2) { + super(dockerImageName); + String apiPath = isVersion2 ? "/api/v2/heartbeat" : "/api/v1/heartbeat"; + dockerImageName.assertCompatibleWith(DEFAULT_DOCKER_IMAGE, GHCR_DOCKER_IMAGE); + withExposedPorts(8000); + waitingFor(Wait.forHttp(apiPath)); + } + + public String getEndpoint() { + return "http://" + getHost() + ":" + getFirstMappedPort(); + } + + private static boolean isVersion2(String version) { + if (version.equals("latest")) { + return true; + } + + ComparableVersion comparableVersion = new ComparableVersion(version); + if (comparableVersion.isGreaterThanOrEqualTo("1.0.0")) { + return true; + } + + log.warn("Version {} is less than 1.0.0 or not a semantic version.", version); + return false; + } +} diff --git a/modules/chromadb/src/test/java/org/testcontainers/chromadb/ChromaDBContainerTest.java b/modules/chromadb/src/test/java/org/testcontainers/chromadb/ChromaDBContainerTest.java new file mode 100644 index 00000000000..579c75f9f36 --- /dev/null +++ b/modules/chromadb/src/test/java/org/testcontainers/chromadb/ChromaDBContainerTest.java @@ -0,0 +1,48 @@ +package org.testcontainers.chromadb; + +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; + +class ChromaDBContainerTest { + + @Test + void test() { + try ( // container { + ChromaDBContainer chroma = new ChromaDBContainer("chromadb/chroma:0.4.23") + // } + ) { + chroma.start(); + + given() + .baseUri(chroma.getEndpoint()) + .when() + .body("{\"name\": \"test\"}") + .contentType(ContentType.JSON) + .post("/api/v1/databases") + .then() + .statusCode(200); + + given().baseUri(chroma.getEndpoint()).when().get("/api/v1/databases/test").then().statusCode(200); + } + } + + @Test + void testVersion2() { + try (ChromaDBContainer chroma = new ChromaDBContainer("chromadb/chroma:1.0.0")) { + chroma.start(); + + given() + .baseUri(chroma.getEndpoint()) + .when() + .body("{\"name\": \"test\"}") + .contentType(ContentType.JSON) + .post("/api/v2/tenants") + .then() + .statusCode(200); + + given().baseUri(chroma.getEndpoint()).when().get("/api/v2/tenants/test").then().statusCode(200); + } + } +} diff --git a/modules/chromadb/src/test/resources/logback-test.xml b/modules/chromadb/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/chromadb/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/clickhouse/build.gradle b/modules/clickhouse/build.gradle new file mode 100644 index 00000000000..59ea169583c --- /dev/null +++ b/modules/clickhouse/build.gradle @@ -0,0 +1,16 @@ +description = "Testcontainers :: JDBC :: ClickHouse" + +dependencies { + api project(':testcontainers') + api project(':testcontainers-jdbc') + + compileOnly project(':testcontainers-r2dbc') + compileOnly(group: 'com.clickhouse', name: 'clickhouse-r2dbc', version: '0.9.8', classifier: 'http') + + testImplementation project(':testcontainers-jdbc-test') + testRuntimeOnly(group: 'com.clickhouse', name: 'clickhouse-jdbc', version: '0.9.8', classifier: 'all') + + testImplementation 'com.clickhouse:client-v2:0.9.8' + testImplementation testFixtures(project(':testcontainers-r2dbc')) + testRuntimeOnly(group: 'com.clickhouse', name: 'clickhouse-r2dbc', version: '0.9.8', classifier: 'http') +} diff --git a/modules/clickhouse/src/main/java/org/testcontainers/clickhouse/ClickHouseContainer.java b/modules/clickhouse/src/main/java/org/testcontainers/clickhouse/ClickHouseContainer.java new file mode 100644 index 00000000000..02a02267ec5 --- /dev/null +++ b/modules/clickhouse/src/main/java/org/testcontainers/clickhouse/ClickHouseContainer.java @@ -0,0 +1,150 @@ +package org.testcontainers.clickhouse; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.HashSet; +import java.util.Set; + +/** + * Testcontainers implementation for ClickHouse. + *

+ * Supported image: {@code clickhouse/clickhouse-server} + *

+ * Exposed ports: + *

    + *
  • Database: 8123
  • + *
  • Console: 9000
  • + *
+ */ +public class ClickHouseContainer extends JdbcDatabaseContainer { + + static final String CLICKHOUSE_CLICKHOUSE_SERVER = "clickhouse/clickhouse-server"; + + private static final DockerImageName CLICKHOUSE_IMAGE_NAME = DockerImageName.parse(CLICKHOUSE_CLICKHOUSE_SERVER); + + static final Integer HTTP_PORT = 8123; + + static final Integer NATIVE_PORT = 9000; + + private static final String LEGACY_V1_DRIVER_CLASS_NAME = "com.clickhouse.jdbc.ClickHouseDriver"; + + private static final String DRIVER_CLASS_NAME = "com.clickhouse.jdbc.Driver"; + + private static final String JDBC_URL_PREFIX = "jdbc:clickhouse://"; + + private static final String TEST_QUERY = "SELECT 1"; + + static final String DEFAULT_USER = "test"; + + static final String DEFAULT_PASSWORD = "test"; + + private String databaseName = "default"; + + private String username = DEFAULT_USER; + + private String password = DEFAULT_PASSWORD; + + public ClickHouseContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public ClickHouseContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(CLICKHOUSE_IMAGE_NAME); + + addExposedPorts(HTTP_PORT, NATIVE_PORT); + waitingFor( + Wait + .forHttp("/") + .forPort(HTTP_PORT) + .forStatusCode(200) + .forResponsePredicate("Ok."::equals) + .withStartupTimeout(Duration.ofMinutes(1)) + ); + } + + @Override + protected void configure() { + withEnv("CLICKHOUSE_DB", this.databaseName); + withEnv("CLICKHOUSE_USER", this.username); + withEnv("CLICKHOUSE_PASSWORD", this.password); + } + + @Override + public Set getLivenessCheckPortNumbers() { + return new HashSet<>(getMappedPort(HTTP_PORT)); + } + + @Override + public String getDriverClassName() { + try { + Class.forName(DRIVER_CLASS_NAME); + return DRIVER_CLASS_NAME; + } catch (ClassNotFoundException e) { + return LEGACY_V1_DRIVER_CLASS_NAME; + } + } + + @Override + public String getJdbcUrl() { + return ( + JDBC_URL_PREFIX + + getHost() + + ":" + + getMappedPort(HTTP_PORT) + + "/" + + this.databaseName + + constructUrlParameters("?", "&") + ); + } + + public String getHttpUrl() { + return "http://" + getHost() + ":" + getMappedPort(HTTP_PORT); + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getDatabaseName() { + return databaseName; + } + + @Override + public String getTestQueryString() { + return TEST_QUERY; + } + + @Override + public ClickHouseContainer withUsername(String username) { + this.username = username; + return this; + } + + @Override + public ClickHouseContainer withPassword(String password) { + this.password = password; + return this; + } + + @Override + public ClickHouseContainer withDatabaseName(String databaseName) { + this.databaseName = databaseName; + return this; + } + + @Override + protected void waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this); + } +} diff --git a/modules/clickhouse/src/main/java/org/testcontainers/clickhouse/ClickHouseR2DBCDatabaseContainer.java b/modules/clickhouse/src/main/java/org/testcontainers/clickhouse/ClickHouseR2DBCDatabaseContainer.java new file mode 100644 index 00000000000..be6d8af67c2 --- /dev/null +++ b/modules/clickhouse/src/main/java/org/testcontainers/clickhouse/ClickHouseR2DBCDatabaseContainer.java @@ -0,0 +1,48 @@ +package org.testcontainers.clickhouse; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; + +/** + * ClickHouse R2DBC support + */ +public class ClickHouseR2DBCDatabaseContainer implements R2DBCDatabaseContainer { + + private final ClickHouseContainer container; + + public ClickHouseR2DBCDatabaseContainer(ClickHouseContainer container) { + this.container = container; + } + + public static ConnectionFactoryOptions getOptions(ClickHouseContainer container) { + ConnectionFactoryOptions options = ConnectionFactoryOptions + .builder() + .option(ConnectionFactoryOptions.DRIVER, ClickHouseR2DBCDatabaseContainerProvider.DRIVER) + .build(); + + return new ClickHouseR2DBCDatabaseContainer(container).configure(options); + } + + @Override + public void start() { + this.container.start(); + } + + @Override + public void stop() { + this.container.stop(); + } + + @Override + public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { + return options + .mutate() + .option(ConnectionFactoryOptions.HOST, container.getHost()) + .option(ConnectionFactoryOptions.PORT, container.getMappedPort(ClickHouseContainer.HTTP_PORT)) + .option(ConnectionFactoryOptions.DATABASE, container.getDatabaseName()) + .option(ConnectionFactoryOptions.USER, container.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, container.getPassword()) + .option(ConnectionFactoryOptions.PROTOCOL, "http") + .build(); + } +} diff --git a/modules/clickhouse/src/main/java/org/testcontainers/clickhouse/ClickHouseR2DBCDatabaseContainerProvider.java b/modules/clickhouse/src/main/java/org/testcontainers/clickhouse/ClickHouseR2DBCDatabaseContainerProvider.java new file mode 100644 index 00000000000..d2005f15bb1 --- /dev/null +++ b/modules/clickhouse/src/main/java/org/testcontainers/clickhouse/ClickHouseR2DBCDatabaseContainerProvider.java @@ -0,0 +1,46 @@ +package org.testcontainers.clickhouse; + +import com.clickhouse.r2dbc.connection.ClickHouseConnectionFactoryProvider; +import io.r2dbc.spi.ConnectionFactoryMetadata; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; +import org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider; + +import javax.annotation.Nullable; + +public class ClickHouseR2DBCDatabaseContainerProvider implements R2DBCDatabaseContainerProvider { + + static final String DRIVER = ClickHouseConnectionFactoryProvider.CLICKHOUSE_DRIVER; + + @Override + public boolean supports(ConnectionFactoryOptions options) { + return DRIVER.equals(options.getRequiredValue(ConnectionFactoryOptions.DRIVER)); + } + + @Override + public R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options) { + String image = + ClickHouseContainer.CLICKHOUSE_CLICKHOUSE_SERVER + ":" + options.getRequiredValue(IMAGE_TAG_OPTION); + ClickHouseContainer container = new ClickHouseContainer(image) + .withDatabaseName((String) options.getRequiredValue(ConnectionFactoryOptions.DATABASE)); + + if (Boolean.TRUE.equals(options.getValue(REUSABLE_OPTION))) { + container.withReuse(true); + } + return new ClickHouseR2DBCDatabaseContainer(container); + } + + @Nullable + @Override + public ConnectionFactoryMetadata getMetadata(ConnectionFactoryOptions options) { + ConnectionFactoryOptions.Builder builder = options.mutate(); + if (!options.hasOption(ConnectionFactoryOptions.USER)) { + builder.option(ConnectionFactoryOptions.USER, ClickHouseContainer.DEFAULT_USER); + } + if (!options.hasOption(ConnectionFactoryOptions.PASSWORD)) { + builder.option(ConnectionFactoryOptions.PASSWORD, ClickHouseContainer.DEFAULT_PASSWORD); + } + builder.option(ConnectionFactoryOptions.PROTOCOL, "http"); + return R2DBCDatabaseContainerProvider.super.getMetadata(builder.build()); + } +} diff --git a/modules/clickhouse/src/main/java/org/testcontainers/containers/ClickHouseContainer.java b/modules/clickhouse/src/main/java/org/testcontainers/containers/ClickHouseContainer.java new file mode 100644 index 00000000000..e9f10feb746 --- /dev/null +++ b/modules/clickhouse/src/main/java/org/testcontainers/containers/ClickHouseContainer.java @@ -0,0 +1,123 @@ +package org.testcontainers.containers; + +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.HashSet; +import java.util.Set; + +/** + * Testcontainers implementation for ClickHouse. + * + * @deprecated use {@link org.testcontainers.clickhouse.ClickHouseContainer} instead + */ +public class ClickHouseContainer extends JdbcDatabaseContainer { + + public static final String NAME = "clickhouse"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("yandex/clickhouse-server"); + + private static final DockerImageName CLICKHOUSE_IMAGE_NAME = DockerImageName.parse("clickhouse/clickhouse-server"); + + @Deprecated + public static final String IMAGE = DEFAULT_IMAGE_NAME.getUnversionedPart(); + + @Deprecated + public static final String DEFAULT_TAG = "18.10.3"; + + public static final Integer HTTP_PORT = 8123; + + public static final Integer NATIVE_PORT = 9000; + + private static final String LEGACY_DRIVER_CLASS_NAME = "ru.yandex.clickhouse.ClickHouseDriver"; + + private static final String DRIVER_CLASS_NAME = "com.clickhouse.jdbc.ClickHouseDriver"; + + private static final String JDBC_URL_PREFIX = "jdbc:" + NAME + "://"; + + private static final String TEST_QUERY = "SELECT 1"; + + private String databaseName = "default"; + + private String username = "default"; + + private String password = ""; + + private boolean supportsNewDriver; + + /** + * @deprecated use {@link #ClickHouseContainer(DockerImageName)} instead + */ + @Deprecated + public ClickHouseContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + public ClickHouseContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public ClickHouseContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, CLICKHOUSE_IMAGE_NAME); + supportsNewDriver = isNewDriverSupported(dockerImageName); + + addExposedPorts(HTTP_PORT, NATIVE_PORT); + this.waitStrategy = + new HttpWaitStrategy() + .forStatusCode(200) + .forResponsePredicate("Ok."::equals) + .withStartupTimeout(Duration.ofMinutes(1)); + } + + @Override + public Set getLivenessCheckPortNumbers() { + return new HashSet<>(getMappedPort(HTTP_PORT)); + } + + @Override + public String getDriverClassName() { + try { + if (supportsNewDriver) { + Class.forName(DRIVER_CLASS_NAME); + return DRIVER_CLASS_NAME; + } else { + return LEGACY_DRIVER_CLASS_NAME; + } + } catch (ClassNotFoundException e) { + return LEGACY_DRIVER_CLASS_NAME; + } + } + + private static boolean isNewDriverSupported(DockerImageName dockerImageName) { + // New driver supports versions 20.7+. Check the version part of the tag + return new ComparableVersion(dockerImageName.getVersionPart()).isGreaterThanOrEqualTo("20.7"); + } + + @Override + public String getJdbcUrl() { + return JDBC_URL_PREFIX + getHost() + ":" + getMappedPort(HTTP_PORT) + "/" + databaseName; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getTestQueryString() { + return TEST_QUERY; + } + + @Override + public ClickHouseContainer withUrlParam(String paramName, String paramValue) { + throw new UnsupportedOperationException("The ClickHouse does not support this"); + } +} diff --git a/modules/clickhouse/src/main/java/org/testcontainers/containers/ClickHouseProvider.java b/modules/clickhouse/src/main/java/org/testcontainers/containers/ClickHouseProvider.java new file mode 100644 index 00000000000..250631c1500 --- /dev/null +++ b/modules/clickhouse/src/main/java/org/testcontainers/containers/ClickHouseProvider.java @@ -0,0 +1,24 @@ +package org.testcontainers.containers; + +import org.testcontainers.clickhouse.ClickHouseContainer; +import org.testcontainers.utility.DockerImageName; + +public class ClickHouseProvider extends JdbcDatabaseContainerProvider { + + private static final String DEFAULT_TAG = "24.12-alpine"; + + @Override + public boolean supports(String databaseType) { + return databaseType.equals("clickhouse"); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(DEFAULT_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + return new ClickHouseContainer(DockerImageName.parse("clickhouse/clickhouse-server").withTag(tag)); + } +} diff --git a/modules/clickhouse/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/clickhouse/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 00000000000..63e99284d2b --- /dev/null +++ b/modules/clickhouse/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.containers.ClickHouseProvider \ No newline at end of file diff --git a/modules/clickhouse/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider b/modules/clickhouse/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider new file mode 100644 index 00000000000..4898be97d99 --- /dev/null +++ b/modules/clickhouse/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.clickhouse.ClickHouseR2DBCDatabaseContainerProvider diff --git a/modules/clickhouse/src/test/java/org/testcontainers/ClickhouseTestImages.java b/modules/clickhouse/src/test/java/org/testcontainers/ClickhouseTestImages.java new file mode 100644 index 00000000000..eff4e19f70b --- /dev/null +++ b/modules/clickhouse/src/test/java/org/testcontainers/ClickhouseTestImages.java @@ -0,0 +1,9 @@ +package org.testcontainers; + +import org.testcontainers.utility.DockerImageName; + +public interface ClickhouseTestImages { + DockerImageName CLICKHOUSE_IMAGE = DockerImageName.parse("clickhouse/clickhouse-server:21.11.11-alpine"); + + DockerImageName CLICKHOUSE_24_12_IMAGE = DockerImageName.parse("clickhouse/clickhouse-server:24.12-alpine"); +} diff --git a/modules/clickhouse/src/test/java/org/testcontainers/clickhouse/ClickHouseContainerTest.java b/modules/clickhouse/src/test/java/org/testcontainers/clickhouse/ClickHouseContainerTest.java new file mode 100644 index 00000000000..0f9eb3ef6a3 --- /dev/null +++ b/modules/clickhouse/src/test/java/org/testcontainers/clickhouse/ClickHouseContainerTest.java @@ -0,0 +1,92 @@ +package org.testcontainers.clickhouse; + +import com.clickhouse.client.api.Client; +import com.clickhouse.client.api.data_formats.ClickHouseBinaryFormatReader; +import com.clickhouse.client.api.query.QueryResponse; +import org.junit.jupiter.api.Test; +import org.testcontainers.ClickhouseTestImages; +import org.testcontainers.db.AbstractContainerDatabaseTest; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +class ClickHouseContainerTest extends AbstractContainerDatabaseTest { + + @Test + void testSimple() throws SQLException { + try ( // container { + ClickHouseContainer clickhouse = new ClickHouseContainer("clickhouse/clickhouse-server:21.11-alpine") + // } + ) { + clickhouse.start(); + + ResultSet resultSet = performQuery(clickhouse, "SELECT 1"); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).isEqualTo(1); + } + } + + @Test + void customCredentialsWithUrlParams() throws SQLException { + try ( + ClickHouseContainer clickhouse = new ClickHouseContainer("clickhouse/clickhouse-server:21.11.2-alpine") + .withUsername("default") + .withPassword("") + .withDatabaseName("test") + // The new driver uses the prefix `clickhouse_setting_` for session settings + .withUrlParam("clickhouse_setting_max_result_rows", "5") + ) { + clickhouse.start(); + + ResultSet resultSet = performQuery( + clickhouse, + "SELECT value FROM system.settings where name='max_result_rows'" + ); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).isEqualTo(5); + } + } + + @Test + void testNewAuth() throws SQLException { + try (ClickHouseContainer clickhouse = new ClickHouseContainer(ClickhouseTestImages.CLICKHOUSE_24_12_IMAGE)) { + clickhouse.start(); + + ResultSet resultSet = performQuery(clickhouse, "SELECT 1"); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).isEqualTo(1); + } + } + + @Test + void testGetHttpMethodWithHttpClient() { + ClickHouseContainer clickhouse = new ClickHouseContainer(ClickhouseTestImages.CLICKHOUSE_24_12_IMAGE); + clickhouse.start(); + Client client = new Client.Builder() + .addEndpoint(clickhouse.getHttpUrl()) + .setUsername(clickhouse.getUsername()) + .setPassword(clickhouse.getPassword()) + .build(); + try { + QueryResponse queryResponse = client.query("SELECT 1").get(1, TimeUnit.MINUTES); + ClickHouseBinaryFormatReader reader = client.newBinaryFormatReader(queryResponse); + reader.next(); + int result = reader.getInteger(1); + assertThat(result).isEqualTo(1); + } catch (ExecutionException | InterruptedException | TimeoutException e) { + fail("Cannot get sql result:" + e); + } finally { + clickhouse.close(); + client.close(); + } + } +} diff --git a/modules/clickhouse/src/test/java/org/testcontainers/clickhouse/ClickHouseR2DBCDatabaseContainerTest.java b/modules/clickhouse/src/test/java/org/testcontainers/clickhouse/ClickHouseR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..000fff8983a --- /dev/null +++ b/modules/clickhouse/src/test/java/org/testcontainers/clickhouse/ClickHouseR2DBCDatabaseContainerTest.java @@ -0,0 +1,22 @@ +package org.testcontainers.clickhouse; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.r2dbc.AbstractR2DBCDatabaseContainerTest; + +public class ClickHouseR2DBCDatabaseContainerTest extends AbstractR2DBCDatabaseContainerTest { + + @Override + protected ConnectionFactoryOptions getOptions(ClickHouseContainer container) { + return ClickHouseR2DBCDatabaseContainer.getOptions(container); + } + + @Override + protected String createR2DBCUrl() { + return "r2dbc:tc:clickhouse:///db?TC_IMAGE_TAG=21.11.11-alpine"; + } + + @Override + protected ClickHouseContainer createContainer() { + return new ClickHouseContainer("clickhouse/clickhouse-server:21.11.11-alpine"); + } +} diff --git a/modules/clickhouse/src/test/java/org/testcontainers/jdbc/clickhouse/ClickhouseJDBCDriverTest.java b/modules/clickhouse/src/test/java/org/testcontainers/jdbc/clickhouse/ClickhouseJDBCDriverTest.java new file mode 100644 index 00000000000..0a85a26899a --- /dev/null +++ b/modules/clickhouse/src/test/java/org/testcontainers/jdbc/clickhouse/ClickhouseJDBCDriverTest.java @@ -0,0 +1,17 @@ +package org.testcontainers.jdbc.clickhouse; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +class ClickhouseJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { // + { "jdbc:tc:clickhouse://hostname/databasename", EnumSet.of(Options.PmdKnownBroken) }, + } + ); + } +} diff --git a/modules/clickhouse/src/test/java/org/testcontainers/junit/clickhouse/SimpleClickhouseTest.java b/modules/clickhouse/src/test/java/org/testcontainers/junit/clickhouse/SimpleClickhouseTest.java new file mode 100644 index 00000000000..c9bd9e917f5 --- /dev/null +++ b/modules/clickhouse/src/test/java/org/testcontainers/junit/clickhouse/SimpleClickhouseTest.java @@ -0,0 +1,26 @@ +package org.testcontainers.junit.clickhouse; + +import org.junit.jupiter.api.Test; +import org.testcontainers.ClickhouseTestImages; +import org.testcontainers.containers.ClickHouseContainer; +import org.testcontainers.db.AbstractContainerDatabaseTest; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +class SimpleClickhouseTest extends AbstractContainerDatabaseTest { + + @Test + public void testSimple() throws SQLException { + try (ClickHouseContainer clickhouse = new ClickHouseContainer(ClickhouseTestImages.CLICKHOUSE_IMAGE)) { + clickhouse.start(); + + ResultSet resultSet = performQuery(clickhouse, "SELECT 1"); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + } + } +} diff --git a/modules/clickhouse/src/test/resources/logback-test.xml b/modules/clickhouse/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/clickhouse/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/cockroachdb/build.gradle b/modules/cockroachdb/build.gradle new file mode 100644 index 00000000000..275e69eef3d --- /dev/null +++ b/modules/cockroachdb/build.gradle @@ -0,0 +1,9 @@ +description = "Testcontainers :: JDBC :: CockroachDB" + +dependencies { + api project(':testcontainers-jdbc') + + testRuntimeOnly 'org.postgresql:postgresql:42.7.10' + + testImplementation project(':testcontainers-jdbc-test') +} diff --git a/modules/cockroachdb/src/main/java/org/testcontainers/cockroachdb/CockroachContainer.java b/modules/cockroachdb/src/main/java/org/testcontainers/cockroachdb/CockroachContainer.java new file mode 100644 index 00000000000..0d49e8cdaf7 --- /dev/null +++ b/modules/cockroachdb/src/main/java/org/testcontainers/cockroachdb/CockroachContainer.java @@ -0,0 +1,158 @@ +package org.testcontainers.cockroachdb; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; + +/** + * Testcontainers implementation for CockroachDB. + *

+ * Supported image: {@code cockroachdb/cockroach} + *

+ * Exposed ports: + *

    + *
  • Database: 26257
  • + *
  • Console: 8080
  • + *
+ */ +public class CockroachContainer extends JdbcDatabaseContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("cockroachdb/cockroach"); + + public static final String NAME = "cockroach"; + + private static final String JDBC_DRIVER_CLASS_NAME = "org.postgresql.Driver"; + + private static final String JDBC_URL_PREFIX = "jdbc:postgresql"; + + private static final String TEST_QUERY_STRING = "SELECT 1"; + + private static final int REST_API_PORT = 8080; + + private static final int DB_PORT = 26257; + + private static final String FIRST_VERSION_WITH_ENV_VARS_SUPPORT = "22.1.0"; + + private String databaseName = "postgres"; + + private String username = "root"; + + private String password = ""; + + private boolean isVersionGreaterThanOrEqualTo221; + + public CockroachContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public CockroachContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + this.isVersionGreaterThanOrEqualTo221 = isVersionGreaterThanOrEqualTo221(dockerImageName); + + WaitAllStrategy waitStrategy = new WaitAllStrategy(); + waitStrategy.withStrategy( + Wait.forHttp("/health").forPort(REST_API_PORT).forStatusCode(200).withStartupTimeout(Duration.ofMinutes(1)) + ); + if (this.isVersionGreaterThanOrEqualTo221) { + waitStrategy.withStrategy(Wait.forSuccessfulCommand("[ -f ./init_success ] || { exit 1; }")); + } + + withExposedPorts(REST_API_PORT, DB_PORT); + waitingFor(waitStrategy); + withCommand("start-single-node --insecure"); + } + + @Override + protected void configure() { + withEnv("COCKROACH_USER", this.username); + withEnv("COCKROACH_PASSWORD", this.password); + if (this.password != null && !this.password.isEmpty()) { + withCommand("start-single-node"); + } + withEnv("COCKROACH_DATABASE", this.databaseName); + } + + @Override + public String getDriverClassName() { + return JDBC_DRIVER_CLASS_NAME; + } + + @Override + public String getJdbcUrl() { + String additionalUrlParams = constructUrlParameters("?", "&"); + return ( + JDBC_URL_PREFIX + + "://" + + getHost() + + ":" + + getMappedPort(DB_PORT) + + "/" + + databaseName + + additionalUrlParams + ); + } + + @Override + public final String getDatabaseName() { + return databaseName; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getTestQueryString() { + return TEST_QUERY_STRING; + } + + @Override + public CockroachContainer withUsername(String username) { + validateIfVersionSupportsUsernameOrPasswordOrDatabase("username"); + this.username = username; + return this; + } + + @Override + public CockroachContainer withPassword(String password) { + validateIfVersionSupportsUsernameOrPasswordOrDatabase("password"); + this.password = password; + return this; + } + + @Override + public CockroachContainer withDatabaseName(final String databaseName) { + validateIfVersionSupportsUsernameOrPasswordOrDatabase("databaseName"); + this.databaseName = databaseName; + return this; + } + + private boolean isVersionGreaterThanOrEqualTo221(DockerImageName dockerImageName) { + ComparableVersion version = new ComparableVersion(dockerImageName.getVersionPart().replaceFirst("v", "")); + return version.isGreaterThanOrEqualTo(FIRST_VERSION_WITH_ENV_VARS_SUPPORT); + } + + private void validateIfVersionSupportsUsernameOrPasswordOrDatabase(String parameter) { + if (!isVersionGreaterThanOrEqualTo221) { + throw new UnsupportedOperationException( + String.format("Setting a %s in not supported in the versions below 22.1.0", parameter) + ); + } + } + + @Override + protected void waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this); + } +} diff --git a/modules/cockroachdb/src/main/java/org/testcontainers/containers/CockroachContainer.java b/modules/cockroachdb/src/main/java/org/testcontainers/containers/CockroachContainer.java new file mode 100644 index 00000000000..461b0467767 --- /dev/null +++ b/modules/cockroachdb/src/main/java/org/testcontainers/containers/CockroachContainer.java @@ -0,0 +1,176 @@ +package org.testcontainers.containers; + +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; + +/** + * Testcontainers implementation for CockroachDB. + *

+ * Supported image: {@code cockroachdb/cockroach} + *

+ * Exposed ports: + *

    + *
  • Database: 26257
  • + *
  • Console: 8080
  • + *
+ * + * @deprecated use {@link org.testcontainers.cockroachdb.CockroachContainer} instead + */ +@Deprecated +public class CockroachContainer extends JdbcDatabaseContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("cockroachdb/cockroach"); + + private static final String DEFAULT_TAG = "v19.2.11"; + + public static final String NAME = "cockroach"; + + @Deprecated + public static final String IMAGE = DEFAULT_IMAGE_NAME.getUnversionedPart(); + + @Deprecated + public static final String IMAGE_TAG = DEFAULT_TAG; + + private static final String JDBC_DRIVER_CLASS_NAME = "org.postgresql.Driver"; + + private static final String JDBC_URL_PREFIX = "jdbc:postgresql"; + + private static final String TEST_QUERY_STRING = "SELECT 1"; + + private static final int REST_API_PORT = 8080; + + private static final int DB_PORT = 26257; + + private static final String FIRST_VERSION_WITH_ENV_VARS_SUPPORT = "22.1.0"; + + private String databaseName = "postgres"; + + private String username = "root"; + + private String password = ""; + + private boolean isVersionGreaterThanOrEqualTo221; + + /** + * @deprecated use {@link #CockroachContainer(DockerImageName)} instead + */ + @Deprecated + public CockroachContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + public CockroachContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public CockroachContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + this.isVersionGreaterThanOrEqualTo221 = isVersionGreaterThanOrEqualTo221(dockerImageName); + + WaitAllStrategy waitStrategy = new WaitAllStrategy(); + waitStrategy.withStrategy( + Wait.forHttp("/health").forPort(REST_API_PORT).forStatusCode(200).withStartupTimeout(Duration.ofMinutes(1)) + ); + if (this.isVersionGreaterThanOrEqualTo221) { + waitStrategy.withStrategy(Wait.forSuccessfulCommand("[ -f ./init_success ] || { exit 1; }")); + } + + withExposedPorts(REST_API_PORT, DB_PORT); + waitingFor(waitStrategy); + withCommand("start-single-node --insecure"); + } + + @Override + protected void configure() { + withEnv("COCKROACH_USER", this.username); + withEnv("COCKROACH_PASSWORD", this.password); + if (this.password != null && !this.password.isEmpty()) { + withCommand("start-single-node"); + } + withEnv("COCKROACH_DATABASE", this.databaseName); + } + + @Override + public String getDriverClassName() { + return JDBC_DRIVER_CLASS_NAME; + } + + @Override + public String getJdbcUrl() { + String additionalUrlParams = constructUrlParameters("?", "&"); + return ( + JDBC_URL_PREFIX + + "://" + + getHost() + + ":" + + getMappedPort(DB_PORT) + + "/" + + databaseName + + additionalUrlParams + ); + } + + @Override + public final String getDatabaseName() { + return databaseName; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getTestQueryString() { + return TEST_QUERY_STRING; + } + + @Override + public CockroachContainer withUsername(String username) { + validateIfVersionSupportsUsernameOrPasswordOrDatabase("username"); + this.username = username; + return this; + } + + @Override + public CockroachContainer withPassword(String password) { + validateIfVersionSupportsUsernameOrPasswordOrDatabase("password"); + this.password = password; + return this; + } + + @Override + public CockroachContainer withDatabaseName(final String databaseName) { + validateIfVersionSupportsUsernameOrPasswordOrDatabase("databaseName"); + this.databaseName = databaseName; + return this; + } + + private boolean isVersionGreaterThanOrEqualTo221(DockerImageName dockerImageName) { + ComparableVersion version = new ComparableVersion(dockerImageName.getVersionPart().replaceFirst("v", "")); + return version.isGreaterThanOrEqualTo(FIRST_VERSION_WITH_ENV_VARS_SUPPORT); + } + + private void validateIfVersionSupportsUsernameOrPasswordOrDatabase(String parameter) { + if (!isVersionGreaterThanOrEqualTo221) { + throw new UnsupportedOperationException( + String.format("Setting a %s in not supported in the versions below 22.1.0", parameter) + ); + } + } + + @Override + protected void waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this); + } +} diff --git a/modules/cockroachdb/src/main/java/org/testcontainers/containers/CockroachContainerProvider.java b/modules/cockroachdb/src/main/java/org/testcontainers/containers/CockroachContainerProvider.java new file mode 100644 index 00000000000..60d2c3aeb15 --- /dev/null +++ b/modules/cockroachdb/src/main/java/org/testcontainers/containers/CockroachContainerProvider.java @@ -0,0 +1,21 @@ +package org.testcontainers.containers; + +import org.testcontainers.utility.DockerImageName; + +public class CockroachContainerProvider extends JdbcDatabaseContainerProvider { + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(CockroachContainer.NAME); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(CockroachContainer.IMAGE_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + return new CockroachContainer(DockerImageName.parse(CockroachContainer.IMAGE).withTag(tag)); + } +} diff --git a/modules/cockroachdb/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/cockroachdb/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 00000000000..3b58b4c6799 --- /dev/null +++ b/modules/cockroachdb/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.containers.CockroachContainerProvider \ No newline at end of file diff --git a/modules/cockroachdb/src/test/java/org/testcontainers/CockroachDBTestImages.java b/modules/cockroachdb/src/test/java/org/testcontainers/CockroachDBTestImages.java new file mode 100644 index 00000000000..0e977cfd065 --- /dev/null +++ b/modules/cockroachdb/src/test/java/org/testcontainers/CockroachDBTestImages.java @@ -0,0 +1,7 @@ +package org.testcontainers; + +import org.testcontainers.utility.DockerImageName; + +public interface CockroachDBTestImages { + DockerImageName COCKROACHDB_IMAGE = DockerImageName.parse("cockroachdb/cockroach:v22.2.3"); +} diff --git a/modules/cockroachdb/src/test/java/org/testcontainers/cockroachdb/CockroachContainerTest.java b/modules/cockroachdb/src/test/java/org/testcontainers/cockroachdb/CockroachContainerTest.java new file mode 100644 index 00000000000..2f4710eae35 --- /dev/null +++ b/modules/cockroachdb/src/test/java/org/testcontainers/cockroachdb/CockroachContainerTest.java @@ -0,0 +1,110 @@ +package org.testcontainers.cockroachdb; + +import org.junit.jupiter.api.Test; +import org.testcontainers.CockroachDBTestImages; +import org.testcontainers.db.AbstractContainerDatabaseTest; +import org.testcontainers.images.builder.Transferable; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.logging.Level; +import java.util.logging.LogManager; + +import static org.assertj.core.api.Assertions.assertThat; + +class CockroachContainerTest extends AbstractContainerDatabaseTest { + static { + // Postgres JDBC driver uses JUL; disable it to avoid annoying, irrelevant, stderr logs during connection testing + LogManager.getLogManager().getLogger("").setLevel(Level.OFF); + } + + @Test + void testSimple() throws SQLException { + try ( // container { + CockroachContainer cockroach = new CockroachContainer("cockroachdb/cockroach:v26.1.1") + // } + ) { + cockroach.start(); + + ResultSet resultSet = performQuery(cockroach, "SELECT 1"); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + } + } + + @Test + void testExplicitInitScript() throws SQLException { + try ( + CockroachContainer cockroach = new CockroachContainer(CockroachDBTestImages.COCKROACHDB_IMAGE) + .withInitScript("somepath/init_postgresql.sql") + ) { // CockroachDB is expected to be compatible with Postgres + cockroach.start(); + + ResultSet resultSet = performQuery(cockroach, "SELECT foo FROM bar"); + + String firstColumnValue = resultSet.getString(1); + assertThat(firstColumnValue).as("Value from init script should equal real value").isEqualTo("hello world"); + } + } + + @Test + void testWithAdditionalUrlParamInJdbcUrl() { + CockroachContainer cockroach = new CockroachContainer(CockroachDBTestImages.COCKROACHDB_IMAGE) + .withUrlParam("sslmode", "disable") + .withUrlParam("application_name", "cockroach"); + + try { + cockroach.start(); + String jdbcUrl = cockroach.getJdbcUrl(); + assertThat(jdbcUrl) + .contains("?") + .contains("&") + .contains("sslmode=disable") + .contains("application_name=cockroach"); + } finally { + cockroach.stop(); + } + } + + @Test + void testWithUsernamePasswordDatabase() throws SQLException { + try ( + CockroachContainer cockroach = new CockroachContainer(CockroachDBTestImages.COCKROACHDB_IMAGE) + .withUsername("test_user") + .withPassword("test_password") + .withDatabaseName("test_database") + ) { + cockroach.start(); + + ResultSet resultSet = performQuery(cockroach, "SELECT 1"); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + + String jdbcUrl = cockroach.getJdbcUrl(); + assertThat(jdbcUrl).contains("/" + "test_database"); + } + } + + @Test + void testInitializationScript() throws SQLException { + String sql = + "USE postgres; \n" + + "CREATE TABLE bar (foo VARCHAR(255)); \n" + + "INSERT INTO bar (foo) VALUES ('hello world');"; + + try ( + CockroachContainer cockroach = new CockroachContainer(CockroachDBTestImages.COCKROACHDB_IMAGE) + .withCopyToContainer(Transferable.of(sql), "/docker-entrypoint-initdb.d/init.sql") + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + ) { // CockroachDB is expected to be compatible with Postgres + cockroach.start(); + + ResultSet resultSet = performQuery(cockroach, "SELECT foo FROM bar"); + + String firstColumnValue = resultSet.getString(1); + assertThat(firstColumnValue).as("Value from init script should equal real value").isEqualTo("hello world"); + } + } +} diff --git a/modules/cockroachdb/src/test/java/org/testcontainers/jdbc/cockroachdb/CockroachDBJDBCDriverTest.java b/modules/cockroachdb/src/test/java/org/testcontainers/jdbc/cockroachdb/CockroachDBJDBCDriverTest.java new file mode 100644 index 00000000000..097aeef005b --- /dev/null +++ b/modules/cockroachdb/src/test/java/org/testcontainers/jdbc/cockroachdb/CockroachDBJDBCDriverTest.java @@ -0,0 +1,17 @@ +package org.testcontainers.jdbc.cockroachdb; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +class CockroachDBJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { // + { "jdbc:tc:cockroach:v22.2.3://hostname/databasename", EnumSet.noneOf(Options.class) }, + } + ); + } +} diff --git a/modules/cockroachdb/src/test/resources/logback-test.xml b/modules/cockroachdb/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/cockroachdb/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/jdbc-test/src/test/resources/somepath/init_mariadb.sql b/modules/cockroachdb/src/test/resources/somepath/init_postgresql.sql similarity index 100% rename from modules/jdbc-test/src/test/resources/somepath/init_mariadb.sql rename to modules/cockroachdb/src/test/resources/somepath/init_postgresql.sql diff --git a/modules/consul/build.gradle b/modules/consul/build.gradle new file mode 100644 index 00000000000..f496a86d6fd --- /dev/null +++ b/modules/consul/build.gradle @@ -0,0 +1,8 @@ +description = "Testcontainers :: Consul" + +dependencies { + api project(':testcontainers') + + testImplementation 'com.ecwid.consul:consul-api:1.4.5' + testImplementation 'io.rest-assured:rest-assured:5.5.7' +} diff --git a/modules/consul/src/main/java/org/testcontainers/consul/ConsulContainer.java b/modules/consul/src/main/java/org/testcontainers/consul/ConsulContainer.java new file mode 100644 index 00000000000..2922d9711ae --- /dev/null +++ b/modules/consul/src/main/java/org/testcontainers/consul/ConsulContainer.java @@ -0,0 +1,110 @@ +package org.testcontainers.consul; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.Capability; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Testcontainers implementation for Consul. + *

+ * Supported images: {@code hashicorp/consul}, {@code consul} + *

+ * Exposed ports: + *

    + *
  • HTTP: 8500
  • + *
  • gRPC: 8502
  • + *
+ */ +public class ConsulContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_OLD_IMAGE_NAME = DockerImageName.parse("consul"); + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("hashicorp/consul"); + + private static final int CONSUL_HTTP_PORT = 8500; + + private static final int CONSUL_GRPC_PORT = 8502; + + private List initCommands = new ArrayList<>(); + + private String[] startConsulCmd = new String[] { "agent", "-dev", "-client", "0.0.0.0" }; + + public ConsulContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public ConsulContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_OLD_IMAGE_NAME, DEFAULT_IMAGE_NAME); + + // Use the status leader endpoint to verify if consul is running. + setWaitStrategy(Wait.forHttp("/v1/status/leader").forPort(CONSUL_HTTP_PORT).forStatusCode(200)); + + withCreateContainerCmdModifier(cmd -> cmd.getHostConfig().withCapAdd(Capability.IPC_LOCK)); + withEnv("CONSUL_ADDR", "http://0.0.0.0:" + CONSUL_HTTP_PORT); + withCommand(startConsulCmd); + withExposedPorts(CONSUL_HTTP_PORT, CONSUL_GRPC_PORT); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + runConsulCommands(); + } + + private void runConsulCommands() { + if (!initCommands.isEmpty()) { + String commands = initCommands + .stream() + .map(command -> "consul " + command) + .collect(Collectors.joining(" && ")); + try { + ExecResult execResult = this.execInContainer(new String[] { "/bin/sh", "-c", commands }); + if (execResult.getExitCode() != 0) { + logger() + .error( + "Failed to execute these init commands {}. Exit code {}. Stdout {}. Stderr {}", + initCommands, + execResult.getExitCode(), + execResult.getStdout(), + execResult.getStderr() + ); + } + } catch (IOException | InterruptedException e) { + logger() + .error( + "Failed to execute these init commands {}. Exception message: {}", + initCommands, + e.getMessage() + ); + } + } + } + + /** + * Run consul commands using the consul cli. + * + * Useful for enabling more secret engines like: + *
+     *     .withConsulCommand("secrets enable pki")
+     *     .withConsulCommand("secrets enable transit")
+     * 
+ * or register specific K/V like: + *
+     *     .withConsulCommand("kv put config/testing1 value123")
+     * 
+ * @param commands The commands to send to the consul cli + * @return this + */ + public ConsulContainer withConsulCommand(String... commands) { + initCommands.addAll(Arrays.asList(commands)); + return self(); + } +} diff --git a/modules/consul/src/test/java/org/testcontainers/consul/ConsulContainerTest.java b/modules/consul/src/test/java/org/testcontainers/consul/ConsulContainerTest.java new file mode 100644 index 00000000000..6e02bff51e8 --- /dev/null +++ b/modules/consul/src/test/java/org/testcontainers/consul/ConsulContainerTest.java @@ -0,0 +1,78 @@ +package org.testcontainers.consul; + +import com.ecwid.consul.v1.ConsulClient; +import com.ecwid.consul.v1.Response; +import com.ecwid.consul.v1.kv.model.GetValue; +import io.restassured.RestAssured; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConsulContainerTest { + + private static ConsulContainer consul = new ConsulContainer("hashicorp/consul:1.15") + .withConsulCommand("kv put config/testing1 value123"); + + @BeforeAll + static void setup() { + consul.start(); + } + + @AfterAll + static void teardown() { + consul.stop(); + } + + @Test + void readFirstPropertyPathWithCli() throws IOException, InterruptedException { + GenericContainer.ExecResult result = consul.execInContainer("consul", "kv", "get", "config/testing1"); + final String output = result.getStdout().replaceAll("\\r?\\n", ""); + assertThat(output).contains("value123"); + } + + @Test + void readFirstSecretPathOverHttpApi() { + io.restassured.response.Response response = RestAssured + .given() + .when() + .get("http://" + getHostAndPort() + "/v1/kv/config/testing1") + .andReturn(); + + assertThat(response.body().jsonPath().getString("[0].Value")) + .isEqualTo(Base64.getEncoder().encodeToString("value123".getBytes(StandardCharsets.UTF_8))); + } + + @Test + void writeAndReadMultipleValuesUsingClient() { + final ConsulClient consulClient = new ConsulClient(consul.getHost(), consul.getFirstMappedPort()); + + final Map properties = new HashMap<>(); + properties.put("value", "world"); + properties.put("other_value", "another world"); + + // Write operation + properties.forEach((key, value) -> { + Response writeResponse = consulClient.setKVValue(key, value); + assertThat(writeResponse.getValue()).isTrue(); + }); + + // Read operation + properties.forEach((key, value) -> { + Response readResponse = consulClient.getKVValue(key); + assertThat(readResponse.getValue().getDecodedValue()).isEqualTo(value); + }); + } + + private String getHostAndPort() { + return consul.getHost() + ":" + consul.getMappedPort(8500); + } +} diff --git a/modules/consul/src/test/resources/logback-test.xml b/modules/consul/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/consul/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/couchbase/AUTHORS b/modules/couchbase/AUTHORS new file mode 100644 index 00000000000..480841c2906 --- /dev/null +++ b/modules/couchbase/AUTHORS @@ -0,0 +1,2 @@ +Tayeb Chlyah +Tobias Happ diff --git a/modules/couchbase/build.gradle b/modules/couchbase/build.gradle new file mode 100644 index 00000000000..068e334e2f1 --- /dev/null +++ b/modules/couchbase/build.gradle @@ -0,0 +1,10 @@ +description = "Testcontainers :: Couchbase" + +dependencies { + api project(':testcontainers') + // TODO use JDK's HTTP client and/or Apache HttpClient5 + shaded 'com.squareup.okhttp3:okhttp:5.5.0' + + testImplementation 'com.couchbase.client:java-client:3.11.2' + testImplementation 'org.awaitility:awaitility:4.3.0' +} diff --git a/modules/couchbase/src/main/java/org/testcontainers/couchbase/BucketDefinition.java b/modules/couchbase/src/main/java/org/testcontainers/couchbase/BucketDefinition.java new file mode 100644 index 00000000000..c698855927c --- /dev/null +++ b/modules/couchbase/src/main/java/org/testcontainers/couchbase/BucketDefinition.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2020 Couchbase, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.testcontainers.couchbase; + +/** + * Allows to configure the properties of a bucket that should be created. + */ +public class BucketDefinition { + + private final String name; + + private boolean flushEnabled = false; + + private boolean queryPrimaryIndex = true; + + private int quota = 100; + + private int numReplicas = 0; + + public BucketDefinition(final String name) { + this.name = name; + } + + /** + * Allows to configure the number of replicas on a bucket (defaults to 0). + *

+ * By default the bucket is initialized with 0 replicas since only a single container is launched. Modifying + * this value can still be useful in some test scenarios (i.e. to test failures with the wrong number of replicas + * and durability requirements on operations). + *

+ * Couchbase buckets can have a maximum of three replicas configured. + * + * @param numReplicas the number of replicas to configure. + * @return this {@link BucketDefinition} for chaining purposes. + */ + public BucketDefinition withReplicas(final int numReplicas) { + if (numReplicas < 0 || numReplicas > 3) { + throw new IllegalArgumentException("The number of replicas must be between 0 and 3 (inclusive)"); + } + this.numReplicas = numReplicas; + return this; + } + + /** + * Enables flush for this bucket (disabled by default). + * + * @param flushEnabled if true, the bucket can be flushed. + * @return this {@link BucketDefinition} for chaining purposes. + */ + public BucketDefinition withFlushEnabled(final boolean flushEnabled) { + this.flushEnabled = flushEnabled; + return this; + } + + /** + * Sets a custom bucket quota (100MiB by default). + * + * @param quota the quota to set for the bucket in mebibytes. + * @return this {@link BucketDefinition} for chaining purposes. + */ + public BucketDefinition withQuota(final int quota) { + if (quota < 100) { + throw new IllegalArgumentException("Bucket quota cannot be less than 100MB!"); + } + this.quota = quota; + return this; + } + + /** + * Allows to disable creating a primary index for this bucket (enabled by default). + * + * @param create if false, a primary index will not be created. + * @return this {@link BucketDefinition} for chaining purposes. + */ + public BucketDefinition withPrimaryIndex(final boolean create) { + this.queryPrimaryIndex = create; + return this; + } + + public String getName() { + return name; + } + + public boolean hasFlushEnabled() { + return flushEnabled; + } + + public boolean hasPrimaryIndex() { + return queryPrimaryIndex; + } + + public int getQuota() { + return quota; + } + + public int getNumReplicas() { + return numReplicas; + } +} diff --git a/modules/couchbase/src/main/java/org/testcontainers/couchbase/CouchbaseContainer.java b/modules/couchbase/src/main/java/org/testcontainers/couchbase/CouchbaseContainer.java new file mode 100644 index 00000000000..14997d253af --- /dev/null +++ b/modules/couchbase/src/main/java/org/testcontainers/couchbase/CouchbaseContainer.java @@ -0,0 +1,891 @@ +/* + * Copyright (c) 2020 Couchbase, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.testcontainers.couchbase; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.ContainerNetwork; +import lombok.Cleanup; +import okhttp3.Credentials; +import okhttp3.FormBody; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.rnorth.ducttape.unreliables.Unreliables; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * Testcontainers implementation for Couchbase. + *

+ * Supported image: {@code couchbase/server} + *

+ * Exposed ports: + *

    + *
  • Console: 8091
  • + *
+ *

+ * Note that it does not depend on a specific couchbase SDK, so it can be used with both the Java SDK 2 and 3 as well + * as the Scala SDK 1 or newer. We recommend using the latest and greatest SDKs for the best experience. + */ +public class CouchbaseContainer extends GenericContainer { + + private static final int MGMT_PORT = 8091; + + private static final int MGMT_SSL_PORT = 18091; + + private static final int VIEW_PORT = 8092; + + private static final int VIEW_SSL_PORT = 18092; + + private static final int QUERY_PORT = 8093; + + private static final int QUERY_SSL_PORT = 18093; + + private static final int SEARCH_PORT = 8094; + + private static final int SEARCH_SSL_PORT = 18094; + + private static final int ANALYTICS_PORT = 8095; + + private static final int ANALYTICS_SSL_PORT = 18095; + + private static final int EVENTING_PORT = 8096; + + private static final int EVENTING_SSL_PORT = 18096; + + private static final int KV_PORT = 11210; + + private static final int KV_SSL_PORT = 11207; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("couchbase/server"); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final OkHttpClient HTTP_CLIENT = new OkHttpClient(); + + private String username = "Administrator"; + + private String password = "password"; + + /** + * Enabled services does not include Analytics since most users likely do not need to test + * with it and is also a little heavy on memory and runtime requirements. Also, it is only + * available with the enterprise edition (EE). + */ + private Set enabledServices = EnumSet.of( + CouchbaseService.KV, + CouchbaseService.QUERY, + CouchbaseService.SEARCH, + CouchbaseService.INDEX + ); + + /** + * Holds the custom service quotas if configured by the user. + */ + private final Map customServiceQuotas = new HashMap<>(); + + private final List buckets = new ArrayList<>(); + + private boolean isEnterprise = false; + + private boolean hasTlsPorts = false; + + /** + * Creates a new couchbase container with the specified image name. + * + * @param dockerImageName the image name that should be used. + */ + public CouchbaseContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + /** + * Create a new couchbase container with the specified image name. + * @param dockerImageName the image name that should be used. + */ + public CouchbaseContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + } + + /** + * Set custom username and password for the admin user. + * + * @param username the admin username to use. + * @param password the password for the admin user. + * @return this {@link CouchbaseContainer} for chaining purposes. + */ + public CouchbaseContainer withCredentials(final String username, final String password) { + checkNotRunning(); + this.username = username; + this.password = password; + return this; + } + + public CouchbaseContainer withBucket(final BucketDefinition bucketDefinition) { + checkNotRunning(); + this.buckets.add(bucketDefinition); + return this; + } + + public CouchbaseContainer withEnabledServices(final CouchbaseService... enabled) { + checkNotRunning(); + this.enabledServices = EnumSet.copyOf(Arrays.asList(enabled)); + return this; + } + + /** + * Configures a custom memory quota for a given service. + * + * @param service the service to configure the quota for. + * @param quotaMb the memory quota in MB. + * @return this {@link CouchbaseContainer} for chaining purposes. + */ + public CouchbaseContainer withServiceQuota(final CouchbaseService service, final int quotaMb) { + checkNotRunning(); + if (!service.hasQuota()) { + throw new IllegalArgumentException("The provided service (" + service + ") has no quota to configure"); + } + if (quotaMb < service.getMinimumQuotaMb()) { + throw new IllegalArgumentException( + "The custom quota (" + + quotaMb + + ") must not be smaller than the " + + "minimum quota for the service (" + + service.getMinimumQuotaMb() + + ")" + ); + } + this.customServiceQuotas.put(service, quotaMb); + return this; + } + + /** + * Enables the analytics service which is not enabled by default. + * + * @return this {@link CouchbaseContainer} for chaining purposes. + */ + public CouchbaseContainer withAnalyticsService() { + checkNotRunning(); + this.enabledServices.add(CouchbaseService.ANALYTICS); + return this; + } + + /** + * Enables the eventing service which is not enabled by default. + * + * @return this {@link CouchbaseContainer} for chaining purposes. + */ + public CouchbaseContainer withEventingService() { + checkNotRunning(); + this.enabledServices.add(CouchbaseService.EVENTING); + return this; + } + + public final String getUsername() { + return username; + } + + public final String getPassword() { + return password; + } + + public int getBootstrapCarrierDirectPort() { + return getMappedPort(KV_PORT); + } + + public int getBootstrapHttpDirectPort() { + return getMappedPort(MGMT_PORT); + } + + public String getConnectionString() { + return String.format("couchbase://%s:%d", getHost(), getBootstrapCarrierDirectPort()); + } + + @Override + protected void configure() { + super.configure(); + + exposePorts(); + + WaitAllStrategy waitStrategy = new WaitAllStrategy(); + + // Makes sure that all nodes in the cluster are healthy. + waitStrategy = + waitStrategy.withStrategy( + new HttpWaitStrategy() + .forPath("/pools/default") + .forPort(MGMT_PORT) + .withBasicCredentials(username, password) + .forStatusCode(200) + .forResponsePredicate(response -> { + try { + return Optional + .of(MAPPER.readTree(response)) + .map(n -> n.at("/nodes/0/status")) + .map(JsonNode::asText) + .map("healthy"::equals) + .orElse(false); + } catch (IOException e) { + logger().error("Unable to parse response: {}", response, e); + return false; + } + }) + ); + + if (enabledServices.contains(CouchbaseService.QUERY)) { + waitStrategy = + waitStrategy.withStrategy( + new HttpWaitStrategy() + .forPath("/admin/ping") + .forPort(QUERY_PORT) + .withBasicCredentials(username, password) + .forStatusCode(200) + ); + } + + if (enabledServices.contains(CouchbaseService.ANALYTICS)) { + waitStrategy = + waitStrategy.withStrategy( + new HttpWaitStrategy() + .forPath("/admin/ping") + .forPort(ANALYTICS_PORT) + .withBasicCredentials(username, password) + .forStatusCode(200) + ); + } + + if (enabledServices.contains(CouchbaseService.EVENTING)) { + waitStrategy = + waitStrategy.withStrategy( + new HttpWaitStrategy() + .forPath("/api/v1/config") + .forPort(EVENTING_PORT) + .withBasicCredentials(username, password) + .forStatusCode(200) + ); + } + + waitingFor(waitStrategy); + } + + /** + * Configures the exposed ports based on the enabled services. + *

+ * Note that the MGMT_PORTs are always enabled since there must always be a cluster + * manager. Also, the View engine ports are implicitly available on the same nodes + * where the KV service is enabled - it is not possible to configure them individually. + */ + private void exposePorts() { + addExposedPorts(MGMT_PORT, MGMT_SSL_PORT); + + if (enabledServices.contains(CouchbaseService.KV)) { + addExposedPorts(KV_PORT, KV_SSL_PORT); + addExposedPorts(VIEW_PORT, VIEW_SSL_PORT); + } + if (enabledServices.contains(CouchbaseService.ANALYTICS)) { + addExposedPorts(ANALYTICS_PORT, ANALYTICS_SSL_PORT); + } + if (enabledServices.contains(CouchbaseService.QUERY)) { + addExposedPorts(QUERY_PORT, QUERY_SSL_PORT); + } + if (enabledServices.contains(CouchbaseService.SEARCH)) { + addExposedPorts(SEARCH_PORT, SEARCH_SSL_PORT); + } + if (enabledServices.contains(CouchbaseService.EVENTING)) { + addExposedPorts(EVENTING_PORT, EVENTING_SSL_PORT); + } + } + + @Override + protected void containerIsStarting(InspectContainerResponse containerInfo, boolean reused) { + if (!reused) { + containerIsStarting(containerInfo); + } + } + + @Override + protected void containerIsStarting(final InspectContainerResponse containerInfo) { + logger().debug("Couchbase container is starting, performing configuration."); + + timePhase("waitUntilNodeIsOnline", this::waitUntilNodeIsOnline); + timePhase("initializeIsEnterprise", this::initializeIsEnterprise); + timePhase("initializeHasTlsPorts", this::initializeHasTlsPorts); + timePhase("renameNode", this::renameNode); + timePhase("initializeServices", this::initializeServices); + timePhase("setMemoryQuotas", this::setMemoryQuotas); + timePhase("configureAdminUser", this::configureAdminUser); + timePhase("configureExternalPorts", this::configureExternalPorts); + + if (enabledServices.contains(CouchbaseService.INDEX)) { + timePhase("configureIndexer", this::configureIndexer); + } + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo, boolean reused) { + if (!reused) { + this.containerIsStarted(containerInfo); + } + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + timePhase("createBuckets", this::createBuckets); + + logger() + .info("Couchbase container is ready! UI available at http://{}:{}", getHost(), getMappedPort(MGMT_PORT)); + } + + /** + * Before we can start configuring the host, we need to wait until the cluster manager is listening. + */ + private void waitUntilNodeIsOnline() { + new HttpWaitStrategy().forPort(MGMT_PORT).forPath("/pools").forStatusCode(200).waitUntilReady(this); + } + + /** + * Fetches edition (enterprise or community) of started container. + */ + private void initializeIsEnterprise() { + @Cleanup + Response response = doHttpRequest(MGMT_PORT, "/pools", "GET", null, true); + + try { + isEnterprise = MAPPER.readTree(response.body().string()).get("isEnterprise").asBoolean(); + } catch (IOException e) { + throw new IllegalStateException("Couchbase /pools did not return valid JSON"); + } + + if (!isEnterprise) { + if (enabledServices.contains(CouchbaseService.ANALYTICS)) { + throw new IllegalStateException("The Analytics Service is only supported with the Enterprise version"); + } + if (enabledServices.contains(CouchbaseService.EVENTING)) { + throw new IllegalStateException("The Eventing Service is only supported with the Enterprise version"); + } + } + } + + /** + * Initializes the {@link #hasTlsPorts} flag. + *

+ * Community Edition might support TLS one happy day, so use a "supports TLS" flag separate from + * the "enterprise edition" flag. + */ + private void initializeHasTlsPorts() { + @Cleanup + Response response = doHttpRequest(MGMT_PORT, "/pools/default/nodeServices", "GET", null, true); + + try { + String clusterTopology = response.body().string(); + hasTlsPorts = + !MAPPER + .readTree(clusterTopology) + .path("nodesExt") + .path(0) + .path("services") + .path("mgmtSSL") + .isMissingNode(); + } catch (IOException e) { + throw new IllegalStateException("Couchbase /pools/default/nodeServices did not return valid JSON"); + } + } + + /** + * Rebinds/renames the internal hostname. + *

+ * To make sure the internal hostname is different from the external (alternate) address and the SDK can pick it + * up automatically, we bind the internal hostname to the internal IP address. + */ + private void renameNode() { + logger().debug("Renaming Couchbase Node from localhost to {}", getHost()); + + @Cleanup + Response response = doHttpRequest( + MGMT_PORT, + "/node/controller/rename", + "POST", + new FormBody.Builder().add("hostname", getInternalIpAddress()).build(), + false + ); + + checkSuccessfulResponse(response, "Could not rename couchbase node"); + } + + /** + * Initializes services based on the configured enabled services. + */ + private void initializeServices() { + logger().debug("Initializing couchbase services on host: {}", enabledServices); + + final String services = enabledServices + .stream() + .map(CouchbaseService::getIdentifier) + .collect(Collectors.joining(",")); + + @Cleanup + Response response = doHttpRequest( + MGMT_PORT, + "/node/controller/setupServices", + "POST", + new FormBody.Builder().add("services", services).build(), + false + ); + + checkSuccessfulResponse(response, "Could not enable couchbase services"); + } + + /** + * Sets the memory quotas for each enabled service. + *

+ * If there is no explicit custom quota defined, the default minimum quota will be used. + */ + private void setMemoryQuotas() { + logger().debug("Custom service memory quotas: {}", customServiceQuotas); + + final FormBody.Builder quotaBuilder = new FormBody.Builder(); + for (CouchbaseService service : enabledServices) { + if (!service.hasQuota()) { + continue; + } + + int quota = customServiceQuotas.getOrDefault(service, service.getMinimumQuotaMb()); + if (CouchbaseService.KV.equals(service)) { + quotaBuilder.add("memoryQuota", Integer.toString(quota)); + } else { + quotaBuilder.add(service.getIdentifier() + "MemoryQuota", Integer.toString(quota)); + } + } + + @Cleanup + Response response = doHttpRequest(MGMT_PORT, "/pools/default", "POST", quotaBuilder.build(), false); + + checkSuccessfulResponse(response, "Could not configure service memory quotas"); + } + + /** + * Configures the admin user on the couchbase node. + *

+ * After this stage, all subsequent API calls need to have the basic auth header set. + */ + private void configureAdminUser() { + logger().debug("Configuring couchbase admin user with username: \"{}\"", username); + + @Cleanup + Response response = doHttpRequest( + MGMT_PORT, + "/settings/web", + "POST", + new FormBody.Builder() + .add("username", username) + .add("password", password) + .add("port", Integer.toString(MGMT_PORT)) + .build(), + false + ); + + checkSuccessfulResponse(response, "Could not configure couchbase admin user"); + } + + /** + * Configures the external ports for SDK access. + *

+ * Since the internal ports are not accessible from outside the container, this code configures the "external" + * hostname and services to align with the mapped ports. The SDK will pick it up and then automatically connect + * to those ports. Note that for all services non-ssl and ssl ports are configured. + */ + private void configureExternalPorts() { + logger().debug("Mapping external ports to the alternate address configuration"); + + final FormBody.Builder builder = new FormBody.Builder(); + builder.add("hostname", getHost()); + builder.add("mgmt", Integer.toString(getMappedPort(MGMT_PORT))); + if (hasTlsPorts) { + builder.add("mgmtSSL", Integer.toString(getMappedPort(MGMT_SSL_PORT))); + } + + if (enabledServices.contains(CouchbaseService.KV)) { + builder.add("kv", Integer.toString(getMappedPort(KV_PORT))); + builder.add("capi", Integer.toString(getMappedPort(VIEW_PORT))); + if (hasTlsPorts) { + builder.add("kvSSL", Integer.toString(getMappedPort(KV_SSL_PORT))); + builder.add("capiSSL", Integer.toString(getMappedPort(VIEW_SSL_PORT))); + } + } + + if (enabledServices.contains(CouchbaseService.QUERY)) { + builder.add("n1ql", Integer.toString(getMappedPort(QUERY_PORT))); + if (hasTlsPorts) { + builder.add("n1qlSSL", Integer.toString(getMappedPort(QUERY_SSL_PORT))); + } + } + + if (enabledServices.contains(CouchbaseService.SEARCH)) { + builder.add("fts", Integer.toString(getMappedPort(SEARCH_PORT))); + if (hasTlsPorts) { + builder.add("ftsSSL", Integer.toString(getMappedPort(SEARCH_SSL_PORT))); + } + } + + if (enabledServices.contains(CouchbaseService.ANALYTICS)) { + builder.add("cbas", Integer.toString(getMappedPort(ANALYTICS_PORT))); + if (hasTlsPorts) { + builder.add("cbasSSL", Integer.toString(getMappedPort(ANALYTICS_SSL_PORT))); + } + } + + if (enabledServices.contains(CouchbaseService.EVENTING)) { + builder.add("eventingAdminPort", Integer.toString(getMappedPort(EVENTING_PORT))); + if (hasTlsPorts) { + builder.add("eventingSSL", Integer.toString(getMappedPort(EVENTING_SSL_PORT))); + } + } + + @Cleanup + Response response = doHttpRequest( + MGMT_PORT, + "/node/controller/setupAlternateAddresses/external", + "PUT", + builder.build(), + true + ); + + checkSuccessfulResponse(response, "Could not configure external ports"); + } + + /** + * Configures the indexer service so that indexes can be created later on the bucket. + */ + private void configureIndexer() { + logger().debug("Configuring the indexer service"); + + @Cleanup + Response response = doHttpRequest( + MGMT_PORT, + "/settings/indexes", + "POST", + new FormBody.Builder().add("storageMode", isEnterprise ? "memory_optimized" : "forestdb").build(), + true + ); + + checkSuccessfulResponse(response, "Could not configure the indexing service"); + } + + /** + * Based on the user-configured bucket definitions, creating buckets and corresponding indexes if needed. + */ + private void createBuckets() { + logger().debug("Creating {} buckets (and corresponding indexes).", buckets.size()); + + for (BucketDefinition bucket : buckets) { + logger().debug("Creating bucket \"{}\"", bucket.getName()); + + @Cleanup + Response response = doHttpRequest( + MGMT_PORT, + "/pools/default/buckets", + "POST", + new FormBody.Builder() + .add("name", bucket.getName()) + .add("ramQuotaMB", Integer.toString(bucket.getQuota())) + .add("flushEnabled", bucket.hasFlushEnabled() ? "1" : "0") + .add("replicaNumber", Integer.toString(bucket.getNumReplicas())) + .build(), + true + ); + + checkSuccessfulResponse(response, "Could not create bucket " + bucket.getName()); + + timePhase( + "createBucket:" + bucket.getName() + ":waitForAllServicesEnabled", + () -> { + new HttpWaitStrategy() + .forPath("/pools/default/b/" + bucket.getName()) + .forPort(MGMT_PORT) + .withBasicCredentials(username, password) + .forStatusCode(200) + .forResponsePredicate(new AllServicesEnabledPredicate()) + .waitUntilReady(this); + } + ); + + if (enabledServices.contains(CouchbaseService.QUERY)) { + // If the query service is enabled, make sure that we only proceed if the query engine also + // knows about the bucket in its metadata configuration. + timePhase( + "createBucket:" + bucket.getName() + ":queryKeyspacePresent", + () -> { + Unreliables.retryUntilTrue( + 1, + TimeUnit.MINUTES, + () -> { + @Cleanup + Response queryResponse = doHttpRequest( + QUERY_PORT, + "/query/service", + "POST", + new FormBody.Builder() + .add( + "statement", + "SELECT COUNT(*) > 0 as present FROM system:keyspaces WHERE name = \"" + + bucket.getName() + + "\"" + ) + .build(), + true + ); + + String body = queryResponse.body() != null ? queryResponse.body().string() : null; + checkSuccessfulResponse( + queryResponse, + "Could not poll query service state for bucket: " + bucket.getName() + ); + + return Optional + .of(MAPPER.readTree(body)) + .map(n -> n.at("/results/0/present")) + .map(JsonNode::asBoolean) + .orElse(false); + } + ); + } + ); + } + + if (bucket.hasPrimaryIndex()) { + if (enabledServices.contains(CouchbaseService.QUERY)) { + @Cleanup + Response queryResponse = doHttpRequest( + QUERY_PORT, + "/query/service", + "POST", + new FormBody.Builder() + .add("statement", "CREATE PRIMARY INDEX on `" + bucket.getName() + "`") + .build(), + true + ); + + try { + checkSuccessfulResponse( + queryResponse, + "Could not create primary index for bucket " + bucket.getName() + ); + } catch (IllegalStateException ex) { + // potentially ignore the error, the index will be eventually built. + if (!ex.getMessage().contains("Index creation will be retried in background")) { + throw ex; + } + } + + timePhase( + "createBucket:" + bucket.getName() + ":primaryIndexOnline", + () -> { + Unreliables.retryUntilTrue( + 1, + TimeUnit.MINUTES, + () -> { + @Cleanup + Response stateResponse = doHttpRequest( + QUERY_PORT, + "/query/service", + "POST", + new FormBody.Builder() + .add( + "statement", + "SELECT count(*) > 0 AS online FROM system:indexes where keyspace_id = \"" + + bucket.getName() + + "\" and is_primary = true and state = \"online\"" + ) + .build(), + true + ); + + String body = stateResponse.body() != null ? stateResponse.body().string() : null; + checkSuccessfulResponse( + stateResponse, + "Could not poll primary index state for bucket: " + bucket.getName() + ); + + return Optional + .of(MAPPER.readTree(body)) + .map(n -> n.at("/results/0/online")) + .map(JsonNode::asBoolean) + .orElse(false); + } + ); + } + ); + } else { + logger() + .info( + "Primary index creation for bucket {} ignored, since QUERY service is not present.", + bucket.getName() + ); + } + } + } + } + + /** + * Helper method to extract the internal IP address based on the network configuration. + */ + private String getInternalIpAddress() { + return getContainerInfo() + .getNetworkSettings() + .getNetworks() + .values() + .stream() + .findFirst() + .map(ContainerNetwork::getIpAddress) + .orElseThrow(() -> new IllegalStateException("No network available to extract the internal IP from!")); + } + + /** + * Helper method to check if the response is successful and release the body if needed. + * + * @param response the response to check. + * @param message the message that should be part of the exception of not successful. + */ + private void checkSuccessfulResponse(final Response response, final String message) { + if (!response.isSuccessful()) { + String body = null; + if (response.body() != null) { + try { + body = response.body().string(); + } catch (IOException e) { + logger().debug("Unable to read body of response: {}", response, e); + } + } + + throw new IllegalStateException(message + ": " + response + ", body=" + (body == null ? "" : body)); + } + } + + /** + * Checks if already running and if so raises an exception to prevent too-late setters. + */ + private void checkNotRunning() { + if (isRunning()) { + throw new IllegalStateException("Setter can only be called before the container is running"); + } + } + + /** + * Helper method to perform a request against a couchbase server HTTP endpoint. + * + * @param port the (unmapped) original port that should be used. + * @param path the relative http path. + * @param method the http method to use. + * @param body if present, will be part of the payload. + * @param auth if authentication with the admin user and password should be used. + * @return the response of the request. + */ + private Response doHttpRequest( + final int port, + final String path, + final String method, + final RequestBody body, + final boolean auth + ) { + try { + Request.Builder requestBuilder = new Request.Builder() + .url("http://" + getHost() + ":" + getMappedPort(port) + path); + + if (auth) { + requestBuilder = requestBuilder.header("Authorization", Credentials.basic(username, password)); + } + + if (body == null) { + requestBuilder = requestBuilder.get(); + } else { + requestBuilder = requestBuilder.method(method, body); + } + + return HTTP_CLIENT.newCall(requestBuilder.build()).execute(); + } catch (Exception ex) { + throw new RuntimeException("Could not perform request against couchbase HTTP endpoint ", ex); + } + } + + /** + * Helper method which times an individual phase and logs it for debugging and optimization purposes. + * + * @param name the name of the phase. + * @param toTime the runnable that should be timed. + */ + private void timePhase(final String name, final Runnable toTime) { + long start = System.nanoTime(); + toTime.run(); + long end = System.nanoTime(); + + logger().debug("Phase {} took {}ms", name, TimeUnit.NANOSECONDS.toMillis(end - start)); + } + + /** + * In addition to getting a 200, we need to make sure that all services we need are enabled and available on + * the bucket. + *

+ * Fixes the issue observed in https://github.com/testcontainers/testcontainers-java/issues/2993 + */ + private class AllServicesEnabledPredicate implements Predicate { + + @Override + public boolean test(final String rawConfig) { + try { + for (JsonNode node : MAPPER.readTree(rawConfig).at("/nodesExt")) { + for (CouchbaseService enabledService : enabledServices) { + boolean found = false; + Iterator fieldNames = node.get("services").fieldNames(); + while (fieldNames.hasNext()) { + if (fieldNames.next().startsWith(enabledService.getIdentifier())) { + found = true; + } + } + if (!found) { + logger().trace("Service {} not yet part of config, retrying.", enabledService); + return false; + } + } + } + return true; + } catch (IOException ex) { + logger().error("Unable to parse response: {}", rawConfig, ex); + return false; + } + } + } +} diff --git a/modules/couchbase/src/main/java/org/testcontainers/couchbase/CouchbaseService.java b/modules/couchbase/src/main/java/org/testcontainers/couchbase/CouchbaseService.java new file mode 100644 index 00000000000..d9a9d2ffe3a --- /dev/null +++ b/modules/couchbase/src/main/java/org/testcontainers/couchbase/CouchbaseService.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2020 Couchbase, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.testcontainers.couchbase; + +/** + * Describes the different services that should be exposed on the container. + */ +public enum CouchbaseService { + /** + * Key-Value service. + */ + KV("kv", 256), + + /** + * Query (N1QL) service. + *

+ * Note that the query service has no memory quota, so it is set to 0. + */ + QUERY("n1ql", 0), + + /** + * Search (FTS) service. + */ + SEARCH("fts", 256), + + /** + * Indexing service (needed if QUERY is also used!). + */ + INDEX("index", 256), + + /** + * Analytics service. + */ + ANALYTICS("cbas", 1024), + + /** + * Eventing service. + */ + EVENTING("eventing", 256); + + private final String identifier; + + private final int minimumQuotaMb; + + CouchbaseService(final String identifier, final int minimumQuotaMb) { + this.identifier = identifier; + this.minimumQuotaMb = minimumQuotaMb; + } + + /** + * Returns the internal service identifier. + * + * @return the internal service identifier. + */ + String getIdentifier() { + return identifier; + } + + /** + * Returns the minimum quota for the service in MB. + * + * @return the minimum quota in MB. + */ + int getMinimumQuotaMb() { + return minimumQuotaMb; + } + + /** + * Returns true if the service has a quota that needs to be applied. + * + * @return true if its quota needs to be applied. + */ + boolean hasQuota() { + return minimumQuotaMb > 0; + } +} diff --git a/modules/couchbase/src/test/java/org/testcontainers/couchbase/CouchbaseContainerTest.java b/modules/couchbase/src/test/java/org/testcontainers/couchbase/CouchbaseContainerTest.java new file mode 100644 index 00000000000..b973d5eff2a --- /dev/null +++ b/modules/couchbase/src/test/java/org/testcontainers/couchbase/CouchbaseContainerTest.java @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2020 Couchbase, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.testcontainers.couchbase; + +import com.couchbase.client.java.Bucket; +import com.couchbase.client.java.Cluster; +import com.couchbase.client.java.Collection; +import com.couchbase.client.java.json.JsonObject; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ContainerLaunchException; + +import java.time.Duration; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +class CouchbaseContainerTest { + + private static final String COUCHBASE_IMAGE_ENTERPRISE = "couchbase/server:enterprise-7.0.3"; + + private static final String COUCHBASE_IMAGE_ENTERPRISE_RECENT = "couchbase/server:enterprise-7.6.2"; + + private static final String COUCHBASE_IMAGE_COMMUNITY = "couchbase/server:community-7.0.2"; + + private static final String COUCHBASE_IMAGE_COMMUNITY_RECENT = "couchbase/server:community-7.6.2"; + + @Test + void testBasicContainerUsageForEnterpriseContainer() { + testBasicContainerUsage(COUCHBASE_IMAGE_ENTERPRISE); + } + + @Test + void testBasicContainerUsageForEnterpriseContainerRecent() { + testBasicContainerUsage(COUCHBASE_IMAGE_ENTERPRISE_RECENT); + } + + @Test + void testBasicContainerUsageForCommunityContainer() { + testBasicContainerUsage(COUCHBASE_IMAGE_COMMUNITY); + } + + @Test + void testBasicContainerUsageForCommunityContainerRecent() { + testBasicContainerUsage(COUCHBASE_IMAGE_COMMUNITY_RECENT); + } + + private void testBasicContainerUsage(String couchbaseImage) { + // bucket_definition { + BucketDefinition bucketDefinition = new BucketDefinition("mybucket"); + // } + + try ( + // container_definition { + CouchbaseContainer container = new CouchbaseContainer(couchbaseImage).withBucket(bucketDefinition) + // } + ) { + setUpClient( + container, + cluster -> { + Bucket bucket = cluster.bucket(bucketDefinition.getName()); + bucket.waitUntilReady(Duration.ofSeconds(10L)); + + Collection collection = bucket.defaultCollection(); + + collection.upsert("foo", JsonObject.create().put("key", "value")); + + JsonObject fooObject = collection.get("foo").contentAsObject(); + + assertThat(fooObject.getString("key")).isEqualTo("value"); + } + ); + } + } + + @Test + void testBucketIsFlushableIfEnabled() { + BucketDefinition bucketDefinition = new BucketDefinition("mybucket").withFlushEnabled(true); + + try ( + CouchbaseContainer container = new CouchbaseContainer(COUCHBASE_IMAGE_ENTERPRISE) + .withBucket(bucketDefinition) + ) { + setUpClient( + container, + cluster -> { + Bucket bucket = cluster.bucket(bucketDefinition.getName()); + bucket.waitUntilReady(Duration.ofSeconds(10L)); + + Collection collection = bucket.defaultCollection(); + + collection.upsert("foo", JsonObject.create().put("key", "value")); + + cluster.buckets().flushBucket(bucketDefinition.getName()); + + await().untilAsserted(() -> assertThat(collection.exists("foo").exists()).isFalse()); + } + ); + } + } + + /** + * Make sure that the code fails fast if the Analytics service is enabled on the community + * edition which is not supported. + */ + @Test + void testFailureIfCommunityUsedWithAnalytics() { + try ( + CouchbaseContainer container = new CouchbaseContainer(COUCHBASE_IMAGE_COMMUNITY) + .withEnabledServices(CouchbaseService.KV, CouchbaseService.ANALYTICS) + ) { + assertThatThrownBy(() -> { + setUpClient(container, cluster -> {}); + }) + .isInstanceOf(ContainerLaunchException.class); + } + } + + /** + * Make sure that the code fails fast if the Eventing service is enabled on the community + * edition which is not supported. + */ + @Test + void testFailureIfCommunityUsedWithEventing() { + try ( + CouchbaseContainer container = new CouchbaseContainer(COUCHBASE_IMAGE_COMMUNITY) + .withEnabledServices(CouchbaseService.KV, CouchbaseService.EVENTING) + ) { + assertThatThrownBy(() -> { + setUpClient(container, cluster -> {}); + }) + .isInstanceOf(ContainerLaunchException.class); + } + } + + private void setUpClient(CouchbaseContainer container, Consumer consumer) { + container.start(); + + // cluster_creation { + Cluster cluster = Cluster.connect( + container.getConnectionString(), + container.getUsername(), + container.getPassword() + ); + // } + + try { + consumer.accept(cluster); + } finally { + cluster.disconnect(); + } + } +} diff --git a/modules/couchbase/src/test/resources/logback-test.xml b/modules/couchbase/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/couchbase/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/cratedb/build.gradle b/modules/cratedb/build.gradle new file mode 100644 index 00000000000..62405d559b2 --- /dev/null +++ b/modules/cratedb/build.gradle @@ -0,0 +1,11 @@ +description = "Testcontainers :: JDBC :: CrateDB" + +dependencies { + api project(':testcontainers-jdbc') + + testRuntimeOnly 'org.postgresql:postgresql:42.7.12' + + testImplementation project(':testcontainers-jdbc-test') + + compileOnly 'org.jetbrains:annotations:26.1.0' +} diff --git a/modules/cratedb/src/main/java/org/testcontainers/cratedb/CrateDBContainer.java b/modules/cratedb/src/main/java/org/testcontainers/cratedb/CrateDBContainer.java new file mode 100644 index 00000000000..d91704eba3d --- /dev/null +++ b/modules/cratedb/src/main/java/org/testcontainers/cratedb/CrateDBContainer.java @@ -0,0 +1,128 @@ +package org.testcontainers.cratedb; + +import org.jetbrains.annotations.NotNull; +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.util.Set; + +/** + * Testcontainers implementation for CrateDB. + *

+ * Supported image: {@code crate} + *

+ * Exposed ports: + *

    + *
  • Database: 5432
  • + *
  • Console: 4200
  • + *
+ */ +public class CrateDBContainer extends JdbcDatabaseContainer { + + static final String NAME = "cratedb"; + + static final String IMAGE = "crate"; + + static final String DEFAULT_TAG = "5.3.1"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("crate"); + + static final Integer CRATEDB_PG_PORT = 5432; + + static final Integer CRATEDB_HTTP_PORT = 4200; + + private String databaseName = "crate"; + + private String username = "crate"; + + private String password = "crate"; + + public CrateDBContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public CrateDBContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withCommand("crate -C discovery.type=single-node"); + + waitingFor(Wait.forHttp("/").forPort(CRATEDB_HTTP_PORT).forStatusCode(200)); + + addExposedPort(CRATEDB_PG_PORT); + addExposedPort(CRATEDB_HTTP_PORT); + } + + /** + * @return the ports on which to check if the container is ready + * @deprecated use {@link #getLivenessCheckPortNumbers()} instead + */ + @NotNull + @Override + @Deprecated + protected Set getLivenessCheckPorts() { + return super.getLivenessCheckPorts(); + } + + @Override + public String getDriverClassName() { + return "org.postgresql.Driver"; + } + + @Override + public String getJdbcUrl() { + String additionalUrlParams = constructUrlParameters("?", "&"); + return ( + "jdbc:postgresql://" + + getHost() + + ":" + + getMappedPort(CRATEDB_PG_PORT) + + "/" + + databaseName + + additionalUrlParams + ); + } + + @Override + public String getDatabaseName() { + return databaseName; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getTestQueryString() { + return "SELECT 1"; + } + + @Override + public CrateDBContainer withDatabaseName(final String databaseName) { + this.databaseName = databaseName; + return self(); + } + + @Override + public CrateDBContainer withUsername(final String username) { + this.username = username; + return self(); + } + + @Override + public CrateDBContainer withPassword(final String password) { + this.password = password; + return self(); + } + + @Override + protected void waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this); + } +} diff --git a/modules/cratedb/src/main/java/org/testcontainers/cratedb/CrateDBContainerProvider.java b/modules/cratedb/src/main/java/org/testcontainers/cratedb/CrateDBContainerProvider.java new file mode 100644 index 00000000000..0bf80f051ec --- /dev/null +++ b/modules/cratedb/src/main/java/org/testcontainers/cratedb/CrateDBContainerProvider.java @@ -0,0 +1,36 @@ +package org.testcontainers.cratedb; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.JdbcDatabaseContainerProvider; +import org.testcontainers.jdbc.ConnectionUrl; +import org.testcontainers.utility.DockerImageName; + +/** + * Factory for CrateDB containers using PostgreSQL JDBC driver. + */ +public class CrateDBContainerProvider extends JdbcDatabaseContainerProvider { + + public static final String USER_PARAM = "user"; + + public static final String PASSWORD_PARAM = "password"; + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(CrateDBContainer.NAME); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(CrateDBContainer.DEFAULT_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + return new CrateDBContainer(DockerImageName.parse(CrateDBContainer.IMAGE).withTag(tag)); + } + + @Override + public JdbcDatabaseContainer newInstance(ConnectionUrl connectionUrl) { + return newInstanceFromConnectionUrl(connectionUrl, USER_PARAM, PASSWORD_PARAM); + } +} diff --git a/modules/cratedb/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/cratedb/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 00000000000..ddd58ec8794 --- /dev/null +++ b/modules/cratedb/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.cratedb.CrateDBContainerProvider diff --git a/modules/cratedb/src/test/java/org/testcontainers/CrateDBTestImages.java b/modules/cratedb/src/test/java/org/testcontainers/CrateDBTestImages.java new file mode 100644 index 00000000000..5025d409839 --- /dev/null +++ b/modules/cratedb/src/test/java/org/testcontainers/CrateDBTestImages.java @@ -0,0 +1,7 @@ +package org.testcontainers; + +import org.testcontainers.utility.DockerImageName; + +public interface CrateDBTestImages { + DockerImageName CRATEDB_TEST_IMAGE = DockerImageName.parse("crate:5.2.5"); +} diff --git a/modules/cratedb/src/test/java/org/testcontainers/jdbc/cratedb/CrateDBJDBCDriverTest.java b/modules/cratedb/src/test/java/org/testcontainers/jdbc/cratedb/CrateDBJDBCDriverTest.java new file mode 100644 index 00000000000..2c20afe6680 --- /dev/null +++ b/modules/cratedb/src/test/java/org/testcontainers/jdbc/cratedb/CrateDBJDBCDriverTest.java @@ -0,0 +1,17 @@ +package org.testcontainers.jdbc.cratedb; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +class CrateDBJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { + { "jdbc:tc:cratedb:5.2.3://hostname/crate?user=crate&password=somepwd", EnumSet.noneOf(Options.class) }, + } + ); + } +} diff --git a/modules/cratedb/src/test/java/org/testcontainers/junit/cratedb/SimpleCrateDBTest.java b/modules/cratedb/src/test/java/org/testcontainers/junit/cratedb/SimpleCrateDBTest.java new file mode 100644 index 00000000000..803b9173218 --- /dev/null +++ b/modules/cratedb/src/test/java/org/testcontainers/junit/cratedb/SimpleCrateDBTest.java @@ -0,0 +1,70 @@ +package org.testcontainers.junit.cratedb; + +import org.junit.jupiter.api.Test; +import org.testcontainers.CrateDBTestImages; +import org.testcontainers.cratedb.CrateDBContainer; +import org.testcontainers.db.AbstractContainerDatabaseTest; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.logging.Level; +import java.util.logging.LogManager; + +import static org.assertj.core.api.Assertions.assertThat; + +class SimpleCrateDBTest extends AbstractContainerDatabaseTest { + static { + // Postgres JDBC driver uses JUL; disable it to avoid annoying, irrelevant, stderr logs during connection testing + LogManager.getLogManager().getLogger("").setLevel(Level.OFF); + } + + @Test + void testSimple() throws SQLException { + try ( // container { + CrateDBContainer cratedb = new CrateDBContainer("crate:5.2.5") + // } + ) { + cratedb.start(); + + ResultSet resultSet = performQuery(cratedb, "SELECT 1"); + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + assertHasCorrectExposedAndLivenessCheckPorts(cratedb); + } + } + + @Test + void testCommandOverride() throws SQLException { + try ( + CrateDBContainer cratedb = new CrateDBContainer(CrateDBTestImages.CRATEDB_TEST_IMAGE) + .withCommand("crate -C discovery.type=single-node -C cluster.name=testcontainers") + ) { + cratedb.start(); + + ResultSet resultSet = performQuery(cratedb, "select name from sys.cluster"); + String result = resultSet.getString(1); + assertThat(result).as("cluster name should be overridden").isEqualTo("testcontainers"); + } + } + + @Test + void testExplicitInitScript() throws SQLException { + try ( + CrateDBContainer cratedb = new CrateDBContainer(CrateDBTestImages.CRATEDB_TEST_IMAGE) + .withInitScript("somepath/init_cratedb.sql") + ) { + cratedb.start(); + + ResultSet resultSet = performQuery(cratedb, "SELECT foo FROM bar"); + + String firstColumnValue = resultSet.getString(1); + assertThat(firstColumnValue).as("Value from init script should equal real value").isEqualTo("hello world"); + } + } + + private void assertHasCorrectExposedAndLivenessCheckPorts(CrateDBContainer cratedb) { + assertThat(cratedb.getExposedPorts()).containsExactlyInAnyOrder(5432, 4200); + assertThat(cratedb.getLivenessCheckPortNumbers()) + .containsExactlyInAnyOrder(cratedb.getMappedPort(5432), cratedb.getMappedPort(4200)); + } +} diff --git a/modules/cratedb/src/test/resources/logback-test.xml b/modules/cratedb/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/cratedb/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/cratedb/src/test/resources/somepath/init_cratedb.sql b/modules/cratedb/src/test/resources/somepath/init_cratedb.sql new file mode 100644 index 00000000000..4faa1ede65c --- /dev/null +++ b/modules/cratedb/src/test/resources/somepath/init_cratedb.sql @@ -0,0 +1,6 @@ +CREATE TABLE bar ( + foo STRING +); + +INSERT INTO bar (foo) VALUES ('hello world'); +REFRESH TABLE bar; diff --git a/modules/database-commons/build.gradle b/modules/database-commons/build.gradle new file mode 100644 index 00000000000..345b354d739 --- /dev/null +++ b/modules/database-commons/build.gradle @@ -0,0 +1,5 @@ +description = "Testcontainers :: Database-Commons" + +dependencies { + api project(':testcontainers') +} diff --git a/modules/database-commons/src/main/java/org/testcontainers/delegate/AbstractDatabaseDelegate.java b/modules/database-commons/src/main/java/org/testcontainers/delegate/AbstractDatabaseDelegate.java new file mode 100644 index 00000000000..a84535ed5fd --- /dev/null +++ b/modules/database-commons/src/main/java/org/testcontainers/delegate/AbstractDatabaseDelegate.java @@ -0,0 +1,59 @@ +package org.testcontainers.delegate; + +import java.util.Collection; + +/** + * @param connection to the database + */ +public abstract class AbstractDatabaseDelegate implements DatabaseDelegate { + + /** + * Database connection + */ + private CONNECTION connection; + + private boolean isConnectionStarted = false; + + /** + * Get or create new connection to the database + */ + protected CONNECTION getConnection() { + if (!isConnectionStarted) { + connection = createNewConnection(); + isConnectionStarted = true; + } + return connection; + } + + @Override + public void execute( + Collection statements, + String scriptPath, + boolean continueOnError, + boolean ignoreFailedDrops + ) { + int lineNumber = 0; + for (String statement : statements) { + lineNumber++; + execute(statement, scriptPath, lineNumber, continueOnError, ignoreFailedDrops); + } + } + + @Override + public void close() { + if (isConnectionStarted) { + closeConnectionQuietly(connection); + isConnectionStarted = false; + } + } + + /** + * Quietly close the connection + */ + protected abstract void closeConnectionQuietly(CONNECTION connection); + + /** + * Template method for creating new connections to the database + */ + protected abstract CONNECTION createNewConnection(); +} diff --git a/modules/database-commons/src/main/java/org/testcontainers/delegate/DatabaseDelegate.java b/modules/database-commons/src/main/java/org/testcontainers/delegate/DatabaseDelegate.java new file mode 100644 index 00000000000..e9dd207e112 --- /dev/null +++ b/modules/database-commons/src/main/java/org/testcontainers/delegate/DatabaseDelegate.java @@ -0,0 +1,34 @@ +package org.testcontainers.delegate; + +import java.util.Collection; + +/** + * Database delegate + * + * Gives an abstraction from concrete database + */ +public interface DatabaseDelegate extends AutoCloseable { + /** + * Execute statement by the implementation of the delegate + */ + void execute( + String statement, + String scriptPath, + int lineNumber, + boolean continueOnError, + boolean ignoreFailedDrops + ); + + /** + * Execute collection of statements + */ + void execute(Collection statements, String scriptPath, boolean continueOnError, boolean ignoreFailedDrops); + + /** + * Close connection to the database + * + * Overridden to suppress throwing Exception + */ + @Override + void close(); +} diff --git a/modules/database-commons/src/main/java/org/testcontainers/exception/ConnectionCreationException.java b/modules/database-commons/src/main/java/org/testcontainers/exception/ConnectionCreationException.java new file mode 100644 index 00000000000..0a117ac23d2 --- /dev/null +++ b/modules/database-commons/src/main/java/org/testcontainers/exception/ConnectionCreationException.java @@ -0,0 +1,11 @@ +package org.testcontainers.exception; + +/** + * Inability to create connection to the database + */ +public class ConnectionCreationException extends RuntimeException { + + public ConnectionCreationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/modules/database-commons/src/main/java/org/testcontainers/ext/ScriptScanner.java b/modules/database-commons/src/main/java/org/testcontainers/ext/ScriptScanner.java new file mode 100644 index 00000000000..8350f7fbf38 --- /dev/null +++ b/modules/database-commons/src/main/java/org/testcontainers/ext/ScriptScanner.java @@ -0,0 +1,173 @@ +package org.testcontainers.ext; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Rough lexical parser for SQL scripts. + */ +@RequiredArgsConstructor +class ScriptScanner { + + private final String resource; + + private final String script; + + private final String separator; + + private final String commentPrefix; + + private final String blockCommentStartDelimiter; + + private final String blockCommentEndDelimiter; + + private final Pattern eol = Pattern.compile("[\n\r]+"); + + private final Pattern whitespace = Pattern.compile("\\s+"); + + private final Pattern identifier = Pattern.compile("[a-z][a-z0-9_$]*", Pattern.CASE_INSENSITIVE); + + private final Pattern dollarQuotedStringDelimiter = Pattern.compile("\\$\\w*\\$"); + + private int offset; + + @Getter + private String currentMatch; + + private boolean matches(String substring) { + if (script.startsWith(substring, offset)) { + currentMatch = substring; + offset += currentMatch.length(); + return true; + } else { + currentMatch = ""; + return false; + } + } + + private boolean matches(Pattern regexp) { + Matcher m = regexp.matcher(script); + m.region(offset, script.length()); + if (m.lookingAt()) { + currentMatch = m.group(); + offset = m.end(); + return true; + } else { + currentMatch = ""; + return false; + } + } + + private boolean matchesSingleLineComment() { + /* Matches from commentPrefix to the EOL or end of script */ + if (matches(commentPrefix)) { + Matcher m = eol.matcher(script); + if (m.find(offset)) { + currentMatch = commentPrefix + script.substring(offset, m.end()); + offset = m.end(); + } else { + currentMatch = commentPrefix + script.substring(offset); + offset = script.length(); + } + return true; + } + return false; + } + + private boolean matchesMultilineComment() { + /* Matches from blockCommentStartDelimiter to the next blockCommentEndDelimiter. + * Error, if blockCommentEndDelimiter is not found. */ + if (matches(blockCommentStartDelimiter)) { + int end = script.indexOf(blockCommentEndDelimiter, offset); + if (end < 0) { + throw new ScriptUtils.ScriptParseException( + String.format("Missing block comment end delimiter [%s].", blockCommentEndDelimiter), + resource + ); + } + end += blockCommentEndDelimiter.length(); + currentMatch = blockCommentStartDelimiter + script.substring(offset, end); + offset = end; + return true; + } + return false; + } + + private boolean matchesQuotedString(final char quote) { + if (script.charAt(offset) == quote) { + boolean escaped = false; + for (int i = offset + 1; i < script.length(); i++) { + char c = script.charAt(i); + if (escaped) { + //just skip the escaped character and drop the flag + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == quote) { + currentMatch = script.substring(offset, i + 1); + offset = i + 1; + return true; + } + } + } + return false; + } + + private boolean matchesDollarQuotedString() { + //Matches $$ .... $$ + if (matches(dollarQuotedStringDelimiter)) { + String delimiter = currentMatch; + int end = script.indexOf(delimiter, offset); + if (end < 0) { + throw new ScriptUtils.ScriptParseException( + String.format("Unclosed dollar quoted string [%s].", delimiter), + resource + ); + } + end += delimiter.length(); + currentMatch = delimiter + script.substring(offset, end); + offset = end; + return true; + } + return false; + } + + Lexem next() { + if (offset < script.length()) { + if (matches(separator)) { + return Lexem.SEPARATOR; + } else if (matchesSingleLineComment() || matchesMultilineComment()) { + return Lexem.COMMENT; + } else if ( + matchesQuotedString('\'') || + matchesQuotedString('"') || + matchesQuotedString('`') || + matchesDollarQuotedString() + ) { + return Lexem.QUOTED_STRING; + } else if (matches(identifier)) { + return Lexem.IDENTIFIER; + } else if (matches(whitespace)) { + return Lexem.WHITESPACE; + } else { + currentMatch = String.valueOf(script.charAt(offset++)); + return Lexem.OTHER; + } + } else { + return Lexem.EOF; + } + } + + enum Lexem { + SEPARATOR, + COMMENT, + QUOTED_STRING, + WHITESPACE, + IDENTIFIER, + OTHER, + EOF, + } +} diff --git a/modules/database-commons/src/main/java/org/testcontainers/ext/ScriptSplitter.java b/modules/database-commons/src/main/java/org/testcontainers/ext/ScriptSplitter.java new file mode 100644 index 00000000000..d71f6f3ac3d --- /dev/null +++ b/modules/database-commons/src/main/java/org/testcontainers/ext/ScriptSplitter.java @@ -0,0 +1,127 @@ +package org.testcontainers.ext; + +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.testcontainers.ext.ScriptScanner.Lexem; + +import java.util.List; + +/** + * Performs splitting of an SQL script into statements including + * basic clean-up. + */ +@RequiredArgsConstructor +class ScriptSplitter { + + private final ScriptScanner scanner; + + private final List statements; + + private final StringBuilder sb = new StringBuilder(); + + /** + * Standard parsing: + * 1. Remove comments + * 2. Shrink whitespace and eols + * 3. Split on separator + */ + void split() { + Lexem l; + while ((l = scanner.next()) != Lexem.EOF) { + switch (l) { + case SEPARATOR: + flushStringBuilder(); + break; + case COMMENT: + //skip + break; + case WHITESPACE: + if (sb.length() == 0 || sb.charAt(sb.length() - 1) != ' ') { + sb.append(' '); + } + break; + case IDENTIFIER: + appendMatch(); + if ("begin".equalsIgnoreCase(scanner.getCurrentMatch())) { + compoundStatement(false); + flushStringBuilder(); + } + break; + default: + appendMatch(); + } + } + flushStringBuilder(); + } + + /** + * Compound statement ('create procedure') mode: + * 1. Do not remove comments + * 2. Do not shrink whitespace + * 3. Do not split on separators + * 3. This mode can be recursive + */ + private void compoundStatement(boolean recursive) { + Lexem l; + while ((l = scanner.next()) != Lexem.EOF) { + appendMatch(); + if (Lexem.IDENTIFIER.equals(l)) { + if ("begin".equalsIgnoreCase(scanner.getCurrentMatch())) { + compoundStatement(true); + } else if ("end".equalsIgnoreCase(scanner.getCurrentMatch())) { + if (endOfBlock(recursive)) { + return; + } + } + } + } + flushStringBuilder(); + } + + private boolean endOfBlock(boolean recursive) { + Lexem l; + StringBuilder temporary = new StringBuilder(); + while ((l = scanner.next()) != Lexem.EOF) { + switch (l) { + case COMMENT: + case WHITESPACE: + temporary.append(scanner.getCurrentMatch()); + break; + case SEPARATOR: + //Only whitespace and comments preceded the separator: true end of block + //If it's an internal block, append everything + if (recursive) { + sb.append(temporary); + appendMatch(); + } + return true; + default: + // Semicolon is not recognized as separator: this means that a custom + // separator is used. Still, 'END;' should be a valid end of block + if (";".equals(scanner.getCurrentMatch())) { + if (recursive) { + sb.append(temporary); + } + appendMatch(); + return true; + } + sb.append(temporary); + appendMatch(); + return false; + } + } + return true; + } + + private void appendMatch() { + sb.append(scanner.getCurrentMatch()); + } + + private void flushStringBuilder() { + final String s = sb.toString().trim(); + if (StringUtils.isNotEmpty(s)) { + statements.add(s); + } + sb.setLength(0); + } +} diff --git a/modules/database-commons/src/main/java/org/testcontainers/ext/ScriptUtils.java b/modules/database-commons/src/main/java/org/testcontainers/ext/ScriptUtils.java new file mode 100644 index 00000000000..b78ac4d63c4 --- /dev/null +++ b/modules/database-commons/src/main/java/org/testcontainers/ext/ScriptUtils.java @@ -0,0 +1,361 @@ +/* + * Copyright 2002-2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.testcontainers.ext; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.delegate.DatabaseDelegate; + +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import javax.script.ScriptException; + +/** + * This is a modified version of the Spring-JDBC ScriptUtils class, adapted to reduce + * dependencies and slightly alter the API. + * + * Generic utility methods for working with SQL scripts. Mainly for internal use + * within the framework. + */ +public abstract class ScriptUtils { + + private static final Logger LOGGER = LoggerFactory.getLogger(ScriptUtils.class); + + /** + * Default statement separator within SQL scripts. + */ + public static final String DEFAULT_STATEMENT_SEPARATOR = ";"; + + /** + * Fallback statement separator within SQL scripts. + *

Used if neither a custom defined separator nor the + * {@link #DEFAULT_STATEMENT_SEPARATOR} is present in a given script. + */ + public static final String FALLBACK_STATEMENT_SEPARATOR = "\n"; + + /** + * Default prefix for line comments within SQL scripts. + */ + public static final String DEFAULT_COMMENT_PREFIX = "--"; + + /** + * Default start delimiter for block comments within SQL scripts. + */ + public static final String DEFAULT_BLOCK_COMMENT_START_DELIMITER = "/*"; + + /** + * Default end delimiter for block comments within SQL scripts. + */ + public static final String DEFAULT_BLOCK_COMMENT_END_DELIMITER = "*/"; + + /** + * Prevent instantiation of this utility class. + */ + private ScriptUtils() { + /* no-op */ + } + + /** + * Split an SQL script into separate statements delimited by the provided + * separator string. Each individual statement will be added to the provided + * {@code List}. + *

Within the script, the provided {@code commentPrefix} will be honored: + * any text beginning with the comment prefix and extending to the end of the + * line will be omitted from the output. Similarly, the provided + * {@code blockCommentStartDelimiter} and {@code blockCommentEndDelimiter} + * delimiters will be honored: any text enclosed in a block comment will be + * omitted from the output. In addition, multiple adjacent whitespace characters + * will be collapsed into a single space. + * @param resource the resource from which the script was read + * @param script the SQL script; never {@code null} or empty + * @param separator text separating each statement — typically a ';' or + * newline character; never {@code null} + * @param commentPrefix the prefix that identifies SQL line comments — + * typically "--"; never {@code null} or empty + * @param blockCommentStartDelimiter the start block comment delimiter; + * never {@code null} or empty + * @param blockCommentEndDelimiter the end block comment delimiter; + * never {@code null} or empty + * @param statements the list that will contain the individual statements + */ + public static void splitSqlScript( + String resource, + String script, + String separator, + String commentPrefix, + String blockCommentStartDelimiter, + String blockCommentEndDelimiter, + List statements + ) { + checkArgument(StringUtils.isNotEmpty(script), "script must not be null or empty"); + checkArgument(separator != null, "separator must not be null"); + checkArgument(StringUtils.isNotEmpty(commentPrefix), "commentPrefix must not be null or empty"); + checkArgument( + StringUtils.isNotEmpty(blockCommentStartDelimiter), + "blockCommentStartDelimiter must not be null or empty" + ); + checkArgument( + StringUtils.isNotEmpty(blockCommentEndDelimiter), + "blockCommentEndDelimiter must not be null or empty" + ); + + new ScriptSplitter( + new ScriptScanner( + resource, + script, + separator, + commentPrefix, + blockCommentStartDelimiter, + blockCommentEndDelimiter + ), + statements + ) + .split(); + } + + private static void checkArgument(boolean expression, String errorMessage) { + if (!expression) { + throw new IllegalArgumentException(errorMessage); + } + } + + /** + * Does the provided SQL script contain the specified delimiter? + * @param script the SQL script + * @param delim String delimiting each statement - typically a ';' character + */ + public static boolean containsSqlScriptDelimiters(String script, String delim) { + return containsSqlScriptDelimiters( + "", + script, + DEFAULT_COMMENT_PREFIX, + delim, + DEFAULT_BLOCK_COMMENT_START_DELIMITER, + DEFAULT_BLOCK_COMMENT_END_DELIMITER + ); + } + + /** + * Does the provided SQL script contain the specified delimiter? + * + * @param script the SQL script + * @param delim String delimiting each statement - typically a ';' character + * @param commentPrefix the prefix that identifies comments in the SQL script, + * typically "--" + * @param blockCommentStartDelimiter block comment start delimiter + * @param blockCommentEndDelimiter block comment end delimiter + */ + public static boolean containsSqlScriptDelimiters( + String scriptPath, + String script, + String commentPrefix, + String delim, + String blockCommentStartDelimiter, + String blockCommentEndDelimiter + ) { + ScriptScanner scanner = new ScriptScanner( + scriptPath, + script, + delim, + commentPrefix, + blockCommentStartDelimiter, + blockCommentEndDelimiter + ); + ScriptScanner.Lexem l; + while ((l = scanner.next()) != ScriptScanner.Lexem.EOF) { + if (ScriptScanner.Lexem.SEPARATOR.equals(l)) { + return true; + } + } + return false; + } + + /** + * Load script from classpath and apply it to the given database + * + * @param databaseDelegate database delegate for script execution + * @param initScriptPath the resource to load the init script from + */ + public static void runInitScript(DatabaseDelegate databaseDelegate, String initScriptPath) { + try { + URL resource = Thread.currentThread().getContextClassLoader().getResource(initScriptPath); + if (resource == null) { + resource = ScriptUtils.class.getClassLoader().getResource(initScriptPath); + if (resource == null) { + LOGGER.warn("Could not load classpath init script: {}", initScriptPath); + throw new ScriptLoadException( + "Could not load classpath init script: " + initScriptPath + ". Resource not found." + ); + } + } + String scripts = IOUtils.toString(resource, StandardCharsets.UTF_8); + executeDatabaseScript(databaseDelegate, initScriptPath, scripts); + } catch (IOException e) { + LOGGER.warn("Could not load classpath init script: {}", initScriptPath); + throw new ScriptLoadException("Could not load classpath init script: " + initScriptPath, e); + } catch (ScriptException e) { + LOGGER.error("Error while executing init script: {}", initScriptPath, e); + throw new UncategorizedScriptException("Error while executing init script: " + initScriptPath, e); + } + } + + public static void executeDatabaseScript(DatabaseDelegate databaseDelegate, String scriptPath, String script) + throws ScriptException { + executeDatabaseScript( + databaseDelegate, + scriptPath, + script, + false, + false, + DEFAULT_COMMENT_PREFIX, + DEFAULT_STATEMENT_SEPARATOR, + DEFAULT_BLOCK_COMMENT_START_DELIMITER, + DEFAULT_BLOCK_COMMENT_END_DELIMITER + ); + } + + /** + * Execute the given database script. + *

Statement separators and comments will be removed before executing + * individual statements within the supplied script. + *

Do not use this method to execute DDL if you expect rollback. + * @param databaseDelegate database delegate for script execution + * @param scriptPath the resource (potentially associated with a specific encoding) + * to load the SQL script from + * @param script the raw script content + *@param continueOnError whether or not to continue without throwing an exception + * in the event of an error + * @param ignoreFailedDrops whether or not to continue in the event of specifically + * an error on a {@code DROP} statement + * @param commentPrefix the prefix that identifies comments in the SQL script — + * typically "--" + * @param separator the script statement separator; defaults to + * {@value #DEFAULT_STATEMENT_SEPARATOR} if not specified and falls back to + * {@value #FALLBACK_STATEMENT_SEPARATOR} as a last resort + * @param blockCommentStartDelimiter the start block comment delimiter; never + * {@code null} or empty + * @param blockCommentEndDelimiter the end block comment delimiter; never + * {@code null} or empty @throws ScriptException if an error occurred while executing the SQL script + */ + public static void executeDatabaseScript( + DatabaseDelegate databaseDelegate, + String scriptPath, + String script, + boolean continueOnError, + boolean ignoreFailedDrops, + String commentPrefix, + String separator, + String blockCommentStartDelimiter, + String blockCommentEndDelimiter + ) throws ScriptException { + try { + if (LOGGER.isInfoEnabled()) { + LOGGER.info("Executing database script from " + scriptPath); + } + + long startTime = System.nanoTime(); + List statements = new LinkedList<>(); + + if (separator == null) { + separator = DEFAULT_STATEMENT_SEPARATOR; + } + if ( + !containsSqlScriptDelimiters( + scriptPath, + script, + commentPrefix, + separator, + blockCommentStartDelimiter, + blockCommentEndDelimiter + ) + ) { + separator = FALLBACK_STATEMENT_SEPARATOR; + } + + splitSqlScript( + scriptPath, + script, + separator, + commentPrefix, + blockCommentStartDelimiter, + blockCommentEndDelimiter, + statements + ); + + try (DatabaseDelegate closeableDelegate = databaseDelegate) { + closeableDelegate.execute(statements, scriptPath, continueOnError, ignoreFailedDrops); + } + + long elapsedTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime); + if (LOGGER.isInfoEnabled()) { + LOGGER.info("Executed database script from " + scriptPath + " in " + elapsedTime + " ms."); + } + } catch (Exception ex) { + if (ex instanceof ScriptException) { + throw (ScriptException) ex; + } + + throw new UncategorizedScriptException( + "Failed to execute database script from resource [" + script + "]", + ex + ); + } + } + + public static class ScriptLoadException extends RuntimeException { + + public ScriptLoadException(String message) { + super(message); + } + + public ScriptLoadException(String message, Throwable cause) { + super(message, cause); + } + } + + public static class ScriptParseException extends RuntimeException { + + public ScriptParseException(String format, String scriptPath) { + super(String.format(format, scriptPath)); + } + } + + public static class ScriptStatementFailedException extends RuntimeException { + + public ScriptStatementFailedException(String statement, int lineNumber, String scriptPath) { + this(statement, lineNumber, scriptPath, null); + } + + public ScriptStatementFailedException(String statement, int lineNumber, String scriptPath, Exception ex) { + super(String.format("Script execution failed (%s:%d): %s", scriptPath, lineNumber, statement), ex); + } + } + + public static class UncategorizedScriptException extends RuntimeException { + + public UncategorizedScriptException(String s, Exception ex) { + super(s, ex); + } + } +} diff --git a/modules/database-commons/src/test/java/org/testcontainers/ext/ScriptScannerTest.java b/modules/database-commons/src/test/java/org/testcontainers/ext/ScriptScannerTest.java new file mode 100644 index 00000000000..a82305a0a6e --- /dev/null +++ b/modules/database-commons/src/test/java/org/testcontainers/ext/ScriptScannerTest.java @@ -0,0 +1,54 @@ +package org.testcontainers.ext; + +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.Test; + +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScriptScannerTest { + + @Test + void testHugeStringLiteral() { + String script = "/* a comment */ \"" + StringUtils.repeat('~', 10000) + "\";"; + ScriptScanner scanner = scanner(script); + assertThat(scanner.next()).isEqualTo(ScriptScanner.Lexem.COMMENT); + assertThat(scanner.next()).isEqualTo(ScriptScanner.Lexem.WHITESPACE); + assertThat(scanner.next()).isEqualTo(ScriptScanner.Lexem.QUOTED_STRING); + assertThat(scanner.getCurrentMatch()).matches(Pattern.compile("\"~+\"")); + } + + @Test + void testPgIdentifierWithDollarSigns() { + ScriptScanner scanner = scanner( + "this$is$a$valid$postgreSQL$identifier " + + "$a$While this is a quoted string$a$$ --just followed by a dollar sign" + ); + assertThat(scanner.next()).isEqualTo(ScriptScanner.Lexem.IDENTIFIER); + assertThat(scanner.next()).isEqualTo(ScriptScanner.Lexem.WHITESPACE); + assertThat(scanner.next()).isEqualTo(ScriptScanner.Lexem.QUOTED_STRING); + assertThat(scanner.next()).isEqualTo(ScriptScanner.Lexem.OTHER); + } + + @Test + void testQuotedLiterals() { + ScriptScanner scanner = scanner("'this \\'is a literal' \"this \\\" is a literal\""); + assertThat(scanner.next()).isEqualTo(ScriptScanner.Lexem.QUOTED_STRING); + assertThat(scanner.getCurrentMatch()).isEqualTo("'this \\'is a literal'"); + assertThat(scanner.next()).isEqualTo(ScriptScanner.Lexem.WHITESPACE); + assertThat(scanner.next()).isEqualTo(ScriptScanner.Lexem.QUOTED_STRING); + assertThat(scanner.getCurrentMatch()).isEqualTo("\"this \\\" is a literal\""); + } + + private static ScriptScanner scanner(String script) { + return new ScriptScanner( + "dummy", + script, + ScriptUtils.DEFAULT_STATEMENT_SEPARATOR, + ScriptUtils.DEFAULT_COMMENT_PREFIX, + ScriptUtils.DEFAULT_BLOCK_COMMENT_START_DELIMITER, + ScriptUtils.DEFAULT_BLOCK_COMMENT_END_DELIMITER + ); + } +} diff --git a/modules/database-commons/src/test/java/org/testcontainers/ext/ScriptSplittingTest.java b/modules/database-commons/src/test/java/org/testcontainers/ext/ScriptSplittingTest.java new file mode 100644 index 00000000000..cb0e33162f4 --- /dev/null +++ b/modules/database-commons/src/test/java/org/testcontainers/ext/ScriptSplittingTest.java @@ -0,0 +1,481 @@ +package org.testcontainers.ext; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ScriptSplittingTest { + + @Test + void testStringDemarcation() { + String script = "SELECT 'foo `bar`'; SELECT 'foo -- `bar`'; SELECT 'foo /* `bar`';"; + + List expected = Arrays.asList("SELECT 'foo `bar`'", "SELECT 'foo -- `bar`'", "SELECT 'foo /* `bar`'"); + + splitAndCompare(script, expected); + } + + @Test + void testIssue1547Case1() { + String script = + "create database if not exists ttt;\n" + + "\n" + + "use ttt;\n" + + "\n" + + "create table aaa\n" + + "(\n" + + " id bigint auto_increment primary key,\n" + + " end_time datetime null COMMENT 'end_time',\n" + + " data_status varchar(16) not null\n" + + ") comment 'aaa';\n" + + "\n" + + "create table bbb\n" + + "(\n" + + " id bigint auto_increment primary key\n" + + ") comment 'bbb';"; + + List expected = Arrays.asList( + "create database if not exists ttt", + "use ttt", + "create table aaa ( id bigint auto_increment primary key, end_time datetime null COMMENT 'end_time', data_status varchar(16) not null ) comment 'aaa'", + "create table bbb ( id bigint auto_increment primary key ) comment 'bbb'" + ); + + splitAndCompare(script, expected); + } + + @Test + void testIssue1547Case2() { + String script = + "CREATE TABLE bar (\n" + + " end_time VARCHAR(255)\n" + + ");\n" + + "CREATE TABLE bar (\n" + + " end_time VARCHAR(255)\n" + + ");"; + + List expected = Arrays.asList( + "CREATE TABLE bar ( end_time VARCHAR(255) )", + "CREATE TABLE bar ( end_time VARCHAR(255) )" + ); + + splitAndCompare(script, expected); + } + + @Test + void testSplittingEnquotedSemicolon() { + String script = "CREATE TABLE `bar;bar` (\n" + " end_time VARCHAR(255)\n" + ");"; + + List expected = Arrays.asList("CREATE TABLE `bar;bar` ( end_time VARCHAR(255) )"); + + splitAndCompare(script, expected); + } + + @Test + void testUnusualSemicolonPlacement() { + String script = "SELECT 1;;;;;SELECT 2;\n;SELECT 3\n; SELECT 4;\n SELECT 5"; + + List expected = Arrays.asList("SELECT 1", "SELECT 2", "SELECT 3", "SELECT 4", "SELECT 5"); + + splitAndCompare(script, expected); + } + + @Test + void testCommentedSemicolon() { + String script = + "CREATE TABLE bar (\n" + " foo VARCHAR(255)\n" + "); \nDROP PROCEDURE IF EXISTS -- ;\n" + " count_foo"; + + List expected = Arrays.asList( + "CREATE TABLE bar ( foo VARCHAR(255) )", + "DROP PROCEDURE IF EXISTS count_foo" + ); + + splitAndCompare(script, expected); + } + + @Test + void testStringEscaping() { + String script = + "SELECT \"a /* string literal containing comment characters like -- here\";\n" + + "SELECT \"a 'quoting' \\\"scenario ` involving BEGIN keyword\\\" here\";\n" + + "SELECT * from `bar`;"; + + List expected = Arrays.asList( + "SELECT \"a /* string literal containing comment characters like -- here\"", + "SELECT \"a 'quoting' \\\"scenario ` involving BEGIN keyword\\\" here\"", + "SELECT * from `bar`" + ); + + splitAndCompare(script, expected); + } + + @Test + void testBlockCommentExclusion() { + String script = "INSERT INTO bar (foo) /* ; */ VALUES ('hello world');"; + + List expected = Arrays.asList("INSERT INTO bar (foo) VALUES ('hello world')"); + + splitAndCompare(script, expected); + } + + @Test + void testBeginEndKeywordCorrectDetection() { + String script = + "INSERT INTO something_end (begin_with_the_token, another_field) /*end*/ VALUES /* end */ (' begin ', `end`)-- begin\n;"; + + List expected = Arrays.asList( + "INSERT INTO something_end (begin_with_the_token, another_field) VALUES (' begin ', `end`)" + ); + + splitAndCompare(script, expected); + } + + @Test + void testCommentInStrings() { + String script = + "CREATE TABLE bar (foo VARCHAR(255));\n" + + "\n" + + "/* Insert Values */\n" + + "INSERT INTO bar (foo) values ('--1');\n" + + "INSERT INTO bar (foo) values ('--2');\n" + + "INSERT INTO bar (foo) values ('/* something */');\n" + + "/* INSERT INTO bar (foo) values (' */'); -- '*/;\n" + // purposefully broken, to see if it breaks our splitting + "INSERT INTO bar (foo) values ('foo');"; + + List expected = Arrays.asList( + "CREATE TABLE bar (foo VARCHAR(255))", + "INSERT INTO bar (foo) values ('--1')", + "INSERT INTO bar (foo) values ('--2')", + "INSERT INTO bar (foo) values ('/* something */')", + "'); -- '*/", + "INSERT INTO bar (foo) values ('foo')" + ); + + splitAndCompare(script, expected); + } + + @Test + void testMultipleBeginEndDetection() { + String script = + "CREATE TABLE bar (foo VARCHAR(255));\n" + + "\n" + + "CREATE TABLE gender (gender VARCHAR(255));\n" + + "CREATE TABLE ending (ending VARCHAR(255));\n" + + "CREATE TABLE end2 (end2 VARCHAR(255));\n" + + "CREATE TABLE end_2 (end2 VARCHAR(255));\n" + + "\n" + + "BEGIN\n" + + " INSERT INTO ending values ('ending');\n" + + "END;\n" + + "\n" + + "BEGIN\n" + + " INSERT INTO ending values ('ending');\n" + + "END/*hello*/;\n" + + "\n" + + "BEGIN--Hello\n" + + " INSERT INTO ending values ('ending');\n" + + "END;\n" + + "\n" + + "/*Hello*/BEGIN\n" + + " INSERT INTO ending values ('ending');\n" + + "END;\n" + + "\n" + + "CREATE TABLE foo (bar VARCHAR(255));"; + + List expected = Arrays.asList( + "CREATE TABLE bar (foo VARCHAR(255))", + "CREATE TABLE gender (gender VARCHAR(255))", + "CREATE TABLE ending (ending VARCHAR(255))", + "CREATE TABLE end2 (end2 VARCHAR(255))", + "CREATE TABLE end_2 (end2 VARCHAR(255))", + "BEGIN\n" + " INSERT INTO ending values ('ending');\n" + "END", + "BEGIN\n" + " INSERT INTO ending values ('ending');\n" + "END", + "BEGIN--Hello\n" + " INSERT INTO ending values ('ending');\n" + "END", + "BEGIN\n" + " INSERT INTO ending values ('ending');\n" + "END", + "CREATE TABLE foo (bar VARCHAR(255))" + ); + + splitAndCompare(script, expected); + } + + @Test + void testProcedureBlock() { + String script = + "CREATE PROCEDURE count_foo()\n" + + " BEGIN\n" + + "\n" + + " BEGIN\n" + + " SELECT *\n" + + " FROM bar;\n" + + " SELECT 1\n" + + " FROM dual;\n" + + " END;\n" + + "\n" + + " BEGIN\n" + + " select * from bar;\n" + + " END;\n" + + "\n" + + " -- we can do comments\n" + + "\n" + + " /* including block\n" + + " comments\n" + + " */\n" + + "\n" + + " /* what if BEGIN appears inside a comment? */\n" + + "\n" + + " select \"or what if BEGIN appears inside a literal?\";\n" + + "\n" + + " END /*; */;"; + + List expected = Arrays.asList( + "CREATE PROCEDURE count_foo() BEGIN\n" + + "\n" + + " BEGIN\n" + + " SELECT *\n" + + " FROM bar;\n" + + " SELECT 1\n" + + " FROM dual;\n" + + " END;\n" + + "\n" + + " BEGIN\n" + + " select * from bar;\n" + + " END;\n" + + "\n" + + " -- we can do comments\n" + + "\n" + + " /* including block\n" + + " comments\n" + + " */\n" + + "\n" + + " /* what if BEGIN appears inside a comment? */\n" + + "\n" + + " select \"or what if BEGIN appears inside a literal?\";\n" + + "\n" + + " END" + ); + + splitAndCompare(script, expected); + } + + @Test + void testUnclosedBlockComment() { + String script = "SELECT 'foo `bar`'; /*"; + assertThatThrownBy(() -> doSplit(script, ScriptUtils.DEFAULT_STATEMENT_SEPARATOR)) + .isInstanceOf(ScriptUtils.ScriptParseException.class) + .hasMessageContaining("*/"); + } + + @Test + void testIssue1452Case() { + String script = + "create table test (text VARCHAR(255));\n" + + "\n" + + "/* some comment */\n" + + "insert into `test` (`text`) values ('a b');"; + + List expected = Arrays.asList( + "create table test (text VARCHAR(255))", + "insert into `test` (`text`) values ('a b')" + ); + + splitAndCompare(script, expected); + } + + @Test + void testIfLoopBlocks() { + String script = + "BEGIN\n" + + " rec_loop: LOOP\n" + + " FETCH blah;\n" + + " IF something_wrong THEN LEAVE rec_loop; END IF;\n" + + " do_something_else;\n" + + " END LOOP;\n" + + "END /* final comment */;"; + List expected = Collections.singletonList( + "BEGIN\n" + + " rec_loop: LOOP\n" + + " FETCH blah;\n" + + " IF something_wrong THEN LEAVE rec_loop; END IF;\n" + + " do_something_else;\n" + + " END LOOP;\n" + + "END" + ); + splitAndCompare(script, expected); + } + + @Test + void testIfLoopBlocksSpecificSeparator() { + String script = + "BEGIN\n" + + " rec_loop: LOOP\n" + + " FETCH blah;\n" + + " IF something_wrong THEN LEAVE rec_loop; END IF;\n" + + " do_something_else;\n" + + " END LOOP;\n" + + "END;\n" + + "@\n" + + "CALL something();\n" + + "@\n"; + List expected = Arrays.asList( + "BEGIN\n" + + " rec_loop: LOOP\n" + + " FETCH blah;\n" + + " IF something_wrong THEN LEAVE rec_loop; END IF;\n" + + " do_something_else;\n" + + " END LOOP;\n" + + "END;", + "CALL something();" + ); + splitAndCompare(script, expected, "@"); + } + + @Test + void oracleStyleBlocks() { + String script = "BEGIN END; /\n" + "BEGIN END;"; + List expected = Arrays.asList("BEGIN END;", "BEGIN END;"); + splitAndCompare(script, expected, "/"); + } + + @Test + void testMultiProcedureMySQLScript() { + String script = + "CREATE PROCEDURE doiterate(p1 INT)\n" + + " BEGIN\n" + + " label1: LOOP\n" + + " SET p1 = p1 + 1;\n" + + " IF p1 < 10 THEN\n" + + " ITERATE label1;\n" + + " END IF;\n" + + " LEAVE label1;\n" + + " END LOOP label1;\n" + + " END;\n" + + "\n" + + "CREATE PROCEDURE dowhile()\n" + + " BEGIN\n" + + " DECLARE v1 INT DEFAULT 5;\n" + + " WHILE v1 > 0 DO\n" + + " SET v1 = v1 - 1;\n" + + " END WHILE;\n" + + " END;\n" + + "\n" + + "CREATE PROCEDURE dorepeat(p1 INT)\n" + + " BEGIN\n" + + " SET @x = 0;\n" + + " REPEAT\n" + + " SET @x = @x + 1;\n" + + " UNTIL @x > p1 END REPEAT;\n" + + " END;"; + List expected = Arrays.asList( + "CREATE PROCEDURE doiterate(p1 INT) BEGIN\n" + + " label1: LOOP\n" + + " SET p1 = p1 + 1;\n" + + " IF p1 < 10 THEN\n" + + " ITERATE label1;\n" + + " END IF;\n" + + " LEAVE label1;\n" + + " END LOOP label1;\n" + + " END", + "CREATE PROCEDURE dowhile() BEGIN\n" + + " DECLARE v1 INT DEFAULT 5;\n" + + " WHILE v1 > 0 DO\n" + + " SET v1 = v1 - 1;\n" + + " END WHILE;\n" + + " END", + "CREATE PROCEDURE dorepeat(p1 INT) BEGIN\n" + + " SET @x = 0;\n" + + " REPEAT\n" + + " SET @x = @x + 1;\n" + + " UNTIL @x > p1 END REPEAT;\n" + + " END" + ); + splitAndCompare(script, expected); + } + + @Test + void testDollarQuotedStrings() { + String script = + "CREATE FUNCTION f ()\n" + + "RETURNS INT\n" + + "AS $$\n" + + "BEGIN\n" + + " RETURN 1;\n" + + "END;\n" + + "$$ LANGUAGE plpgsql;"; + List expected = Collections.singletonList( + "CREATE FUNCTION f () RETURNS INT AS $$\n" + + "BEGIN\n" + + " RETURN 1;\n" + + "END;\n" + + "$$ LANGUAGE plpgsql" + ); + splitAndCompare(script, expected); + } + + @Test + void testNestedDollarQuotedString() { + //see https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-DOLLAR-QUOTING + String script = + "CREATE FUNCTION f() AS $function$\n" + + "BEGIN\n" + + " RETURN ($1 ~ $q$[\\t\\r\\n\\v\\\\]$q$);\n" + + "END;\n" + + "$function$;" + + "create table foo ();"; + List expected = Arrays.asList( + "CREATE FUNCTION f() AS $function$\n" + + "BEGIN\n" + + " RETURN ($1 ~ $q$[\\t\\r\\n\\v\\\\]$q$);\n" + + "END;\n" + + "$function$", + "create table foo ()" + ); + splitAndCompare(script, expected); + } + + @Test + void testUnclosedDollarQuotedString() { + String script = "SELECT $tag$ ..... $"; + assertThatThrownBy(() -> doSplit(script, ScriptUtils.DEFAULT_STATEMENT_SEPARATOR)) + .isInstanceOf(ScriptUtils.ScriptParseException.class) + .hasMessageContaining("$tag$"); + } + + private void splitAndCompare(String script, List expected) { + splitAndCompare(script, expected, ScriptUtils.DEFAULT_STATEMENT_SEPARATOR); + } + + private void splitAndCompare(String script, List expected, String separator) { + final List statements = doSplit(script, separator); + assertThat(statements).isEqualTo(expected); + } + + private List doSplit(String script, String separator) { + final List statements = new ArrayList<>(); + ScriptUtils.splitSqlScript( + "ignored", + script, + separator, + ScriptUtils.DEFAULT_COMMENT_PREFIX, + ScriptUtils.DEFAULT_BLOCK_COMMENT_START_DELIMITER, + ScriptUtils.DEFAULT_BLOCK_COMMENT_END_DELIMITER, + statements + ); + return statements; + } + + @Test + void testIgnoreDelimitersInLiteralsAndComments() { + assertThat(ScriptUtils.containsSqlScriptDelimiters("'@' /*@*/ \"@\" $tag$@$tag$ --@", "@")).isFalse(); + } + + @Test + void testContainsDelimiters() { + assertThat(ScriptUtils.containsSqlScriptDelimiters("'@' /*@*/ @ \"@\" --@", "@")).isTrue(); + } +} diff --git a/modules/databend/build.gradle b/modules/databend/build.gradle new file mode 100644 index 00000000000..7a779d3ca20 --- /dev/null +++ b/modules/databend/build.gradle @@ -0,0 +1,8 @@ +description = "Testcontainers :: JDBC :: Databend" + +dependencies { + api project(':testcontainers-jdbc') + + testImplementation project(':testcontainers-jdbc-test') + testRuntimeOnly 'com.databend:databend-jdbc:0.4.6' +} diff --git a/modules/databend/src/main/java/org/testcontainers/databend/DatabendContainer.java b/modules/databend/src/main/java/org/testcontainers/databend/DatabendContainer.java new file mode 100644 index 00000000000..389c8c44963 --- /dev/null +++ b/modules/databend/src/main/java/org/testcontainers/databend/DatabendContainer.java @@ -0,0 +1,112 @@ +package org.testcontainers.databend; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.util.HashSet; +import java.util.Set; + +/** + * Testcontainers implementation for Databend. + *

+ * Supported image: {@code datafuselabs/databend} + *

+ * Exposed ports: + *

    + *
  • Database: 8000
  • + *
+ */ +public class DatabendContainer extends JdbcDatabaseContainer { + + static final String NAME = "databend"; + + static final DockerImageName DOCKER_IMAGE_NAME = DockerImageName.parse("datafuselabs/databend"); + + private static final Integer HTTP_PORT = 8000; + + private static final String DRIVER_CLASS_NAME = "com.databend.jdbc.DatabendDriver"; + + private static final String JDBC_URL_PREFIX = "jdbc:" + NAME + "://"; + + private static final String TEST_QUERY = "SELECT 1"; + + private String databaseName = "default"; + + private String username = "databend"; + + private String password = "databend"; + + public DatabendContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public DatabendContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DOCKER_IMAGE_NAME); + + addExposedPorts(HTTP_PORT); + waitingFor(Wait.forHttp("/").forResponsePredicate(response -> response.equals("Ok."))); + } + + @Override + protected void configure() { + withEnv("QUERY_DEFAULT_USER", this.username); + withEnv("QUERY_DEFAULT_PASSWORD", this.password); + } + + @Override + public Set getLivenessCheckPortNumbers() { + return new HashSet<>(getMappedPort(HTTP_PORT)); + } + + @Override + public String getDriverClassName() { + return DRIVER_CLASS_NAME; + } + + @Override + public String getJdbcUrl() { + return ( + JDBC_URL_PREFIX + + getHost() + + ":" + + getMappedPort(HTTP_PORT) + + "/" + + this.databaseName + + constructUrlParameters("?", "&") + ); + } + + @Override + public String getUsername() { + return this.username; + } + + @Override + public String getPassword() { + return this.password; + } + + @Override + public String getDatabaseName() { + return this.databaseName; + } + + @Override + public String getTestQueryString() { + return TEST_QUERY; + } + + @Override + public DatabendContainer withUsername(String username) { + this.username = username; + return this; + } + + @Override + public DatabendContainer withPassword(String password) { + this.password = password; + return this; + } +} diff --git a/modules/databend/src/main/java/org/testcontainers/databend/DatabendContainerProvider.java b/modules/databend/src/main/java/org/testcontainers/databend/DatabendContainerProvider.java new file mode 100644 index 00000000000..9142bebcfd3 --- /dev/null +++ b/modules/databend/src/main/java/org/testcontainers/databend/DatabendContainerProvider.java @@ -0,0 +1,28 @@ +package org.testcontainers.databend; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.JdbcDatabaseContainerProvider; + +public class DatabendContainerProvider extends JdbcDatabaseContainerProvider { + + private static final String DEFAULT_TAG = "v1.2.615"; + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(DatabendContainer.NAME); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(DEFAULT_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + if (tag != null) { + return new DatabendContainer(DatabendContainer.DOCKER_IMAGE_NAME.withTag(tag)); + } else { + return newInstance(); + } + } +} diff --git a/modules/databend/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/databend/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 00000000000..ead69e77bee --- /dev/null +++ b/modules/databend/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.databend.DatabendContainerProvider diff --git a/modules/databend/src/test/java/org/testcontainers/databend/DatabendContainerTest.java b/modules/databend/src/test/java/org/testcontainers/databend/DatabendContainerTest.java new file mode 100644 index 00000000000..984f42979c4 --- /dev/null +++ b/modules/databend/src/test/java/org/testcontainers/databend/DatabendContainerTest.java @@ -0,0 +1,44 @@ +package org.testcontainers.databend; + +import org.junit.jupiter.api.Test; +import org.testcontainers.db.AbstractContainerDatabaseTest; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +class DatabendContainerTest extends AbstractContainerDatabaseTest { + + @Test + void testSimple() throws SQLException { + try ( // container { + DatabendContainer databend = new DatabendContainer("datafuselabs/databend:v1.2.615") + // } + ) { + databend.start(); + + ResultSet resultSet = performQuery(databend, "SELECT 1"); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).isEqualTo(1); + } + } + + @Test + void customCredentialsWithUrlParams() throws SQLException { + try ( + DatabendContainer databend = new DatabendContainer("datafuselabs/databend:v1.2.615") + .withUsername("test") + .withPassword("test") + .withUrlParam("ssl", "false") + ) { + databend.start(); + + ResultSet resultSet = performQuery(databend, "SELECT 1;"); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).isEqualTo(1); + } + } +} diff --git a/modules/databend/src/test/java/org/testcontainers/databend/DatabendJDBCDriverTest.java b/modules/databend/src/test/java/org/testcontainers/databend/DatabendJDBCDriverTest.java new file mode 100644 index 00000000000..2f3a030919f --- /dev/null +++ b/modules/databend/src/test/java/org/testcontainers/databend/DatabendJDBCDriverTest.java @@ -0,0 +1,17 @@ +package org.testcontainers.databend; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +class DatabendJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { // + { "jdbc:tc:databend://hostname/databasename", EnumSet.of(Options.PmdKnownBroken) }, + } + ); + } +} diff --git a/modules/databend/src/test/resources/logback-test.xml b/modules/databend/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/databend/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/db2/build.gradle b/modules/db2/build.gradle new file mode 100644 index 00000000000..afae0bfd751 --- /dev/null +++ b/modules/db2/build.gradle @@ -0,0 +1,8 @@ +description = "Testcontainers :: JDBC :: DB2" + +dependencies { + api project(':testcontainers-jdbc') + + testImplementation project(':testcontainers-jdbc-test') + testRuntimeOnly 'com.ibm.db2:jcc:12.1.5.0' +} diff --git a/modules/db2/src/main/java/org/testcontainers/containers/Db2Container.java b/modules/db2/src/main/java/org/testcontainers/containers/Db2Container.java new file mode 100644 index 00000000000..19dbc6a58ac --- /dev/null +++ b/modules/db2/src/main/java/org/testcontainers/containers/Db2Container.java @@ -0,0 +1,165 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.model.Capability; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.LicenseAcceptance; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Set; + +/** + * Testcontainers implementation for IBM DB2. + *

+ * Supported images: {@code icr.io/db2_community/db2}, {@code ibmcom/db2} + *

+ * Exposed ports: + *

    + *
  • Database: 50000
  • + *
+ * @deprecated use {@link org.testcontainers.db2.Db2Container} instead. + */ +@Deprecated +public class Db2Container extends JdbcDatabaseContainer { + + public static final String NAME = "db2"; + + @Deprecated + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("ibmcom/db2"); + + private static final DockerImageName DEFAULT_NEW_IMAGE_NAME = DockerImageName.parse("icr.io/db2_community/db2"); + + @Deprecated + public static final String DEFAULT_DB2_IMAGE_NAME = DEFAULT_IMAGE_NAME.getUnversionedPart(); + + @Deprecated + public static final String DEFAULT_TAG = "11.5.0.0a"; + + public static final int DB2_PORT = 50000; + + private String databaseName = "test"; + + private String username = "db2inst1"; + + private String password = "foobar1234"; + + /** + * @deprecated use {@link #Db2Container(DockerImageName)} instead + */ + @Deprecated + public Db2Container() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + public Db2Container(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public Db2Container(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_NEW_IMAGE_NAME, DEFAULT_IMAGE_NAME); + + withCreateContainerCmdModifier(cmd -> cmd.withCapAdd(Capability.IPC_LOCK).withCapAdd(Capability.IPC_OWNER)); + this.waitStrategy = + new LogMessageWaitStrategy() + .withRegEx(".*Setup has completed\\..*") + .withStartupTimeout(Duration.of(10, ChronoUnit.MINUTES)); + + addExposedPort(DB2_PORT); + } + + /** + * @return the ports on which to check if the container is ready + * @deprecated use {@link #getLivenessCheckPortNumbers()} instead + */ + @Override + @Deprecated + protected Set getLivenessCheckPorts() { + return super.getLivenessCheckPorts(); + } + + @Override + protected void configure() { + // If license was not accepted programmatically, check if it was accepted via resource file + if (!getEnvMap().containsKey("LICENSE")) { + LicenseAcceptance.assertLicenseAccepted(this.getDockerImageName()); + acceptLicense(); + } + + addEnv("DBNAME", databaseName); + addEnv("DB2INSTANCE", username); + addEnv("DB2INST1_PASSWORD", password); + + // These settings help the DB2 container start faster + if (!getEnvMap().containsKey("AUTOCONFIG")) { + addEnv("AUTOCONFIG", "false"); + } + if (!getEnvMap().containsKey("ARCHIVE_LOGS")) { + addEnv("ARCHIVE_LOGS", "false"); + } + } + + /** + * Accepts the license for the DB2 container by setting the LICENSE=accept + * variable as described at https://hub.docker.com/r/ibmcom/db2 + */ + public Db2Container acceptLicense() { + addEnv("LICENSE", "accept"); + return this; + } + + @Override + public String getDriverClassName() { + return "com.ibm.db2.jcc.DB2Driver"; + } + + @Override + public String getJdbcUrl() { + String additionalUrlParams = constructUrlParameters(":", ";", ";"); + return "jdbc:db2://" + getHost() + ":" + getMappedPort(DB2_PORT) + "/" + databaseName + additionalUrlParams; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getDatabaseName() { + return databaseName; + } + + @Override + public Db2Container withUsername(String username) { + this.username = username; + return this; + } + + @Override + public Db2Container withPassword(String password) { + this.password = password; + return this; + } + + @Override + public Db2Container withDatabaseName(String dbName) { + this.databaseName = dbName; + return this; + } + + @Override + protected void waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this); + } + + @Override + protected String getTestQueryString() { + return "SELECT 1 FROM SYSIBM.SYSDUMMY1"; + } +} diff --git a/modules/db2/src/main/java/org/testcontainers/containers/Db2ContainerProvider.java b/modules/db2/src/main/java/org/testcontainers/containers/Db2ContainerProvider.java new file mode 100644 index 00000000000..42ac21f271b --- /dev/null +++ b/modules/db2/src/main/java/org/testcontainers/containers/Db2ContainerProvider.java @@ -0,0 +1,21 @@ +package org.testcontainers.containers; + +import org.testcontainers.utility.DockerImageName; + +public class Db2ContainerProvider extends JdbcDatabaseContainerProvider { + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(Db2Container.NAME); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(Db2Container.DEFAULT_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + return new Db2Container(DockerImageName.parse(Db2Container.DEFAULT_DB2_IMAGE_NAME).withTag(tag)); + } +} diff --git a/modules/db2/src/main/java/org/testcontainers/db2/Db2Container.java b/modules/db2/src/main/java/org/testcontainers/db2/Db2Container.java new file mode 100644 index 00000000000..bdd182599e5 --- /dev/null +++ b/modules/db2/src/main/java/org/testcontainers/db2/Db2Container.java @@ -0,0 +1,143 @@ +package org.testcontainers.db2; + +import com.github.dockerjava.api.model.Capability; +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.LicenseAcceptance; + +import java.time.Duration; +import java.util.Set; + +/** + * Testcontainers implementation for IBM DB2. + *

+ * Supported images: {@code icr.io/db2_community/db2}, {@code ibmcom/db2} + *

+ * Exposed ports: + *

    + *
  • Database: 50000
  • + *
+ */ +public class Db2Container extends JdbcDatabaseContainer { + + public static final String NAME = "db2"; + + private static final DockerImageName DEFAULT_NEW_IMAGE_NAME = DockerImageName.parse("icr.io/db2_community/db2"); + + public static final int DB2_PORT = 50000; + + private String databaseName = "test"; + + private String username = "db2inst1"; + + private String password = "foobar1234"; + + public Db2Container(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public Db2Container(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_NEW_IMAGE_NAME); + + withCreateContainerCmdModifier(cmd -> cmd.withCapAdd(Capability.IPC_LOCK).withCapAdd(Capability.IPC_OWNER)); + waitingFor(Wait.forLogMessage(".*Setup has completed\\..*", 1).withStartupTimeout(Duration.ofMinutes(10))); + + addExposedPort(DB2_PORT); + } + + /** + * @return the ports on which to check if the container is ready + * @deprecated use {@link #getLivenessCheckPortNumbers()} instead + */ + @Override + @Deprecated + protected Set getLivenessCheckPorts() { + return super.getLivenessCheckPorts(); + } + + @Override + protected void configure() { + // If license was not accepted programmatically, check if it was accepted via resource file + if (!getEnvMap().containsKey("LICENSE")) { + LicenseAcceptance.assertLicenseAccepted(this.getDockerImageName()); + acceptLicense(); + } + + addEnv("DBNAME", databaseName); + addEnv("DB2INSTANCE", username); + addEnv("DB2INST1_PASSWORD", password); + + // These settings help the DB2 container start faster + if (!getEnvMap().containsKey("AUTOCONFIG")) { + addEnv("AUTOCONFIG", "false"); + } + if (!getEnvMap().containsKey("ARCHIVE_LOGS")) { + addEnv("ARCHIVE_LOGS", "false"); + } + } + + /** + * Accepts the license for the DB2 container by setting the LICENSE=accept + * variable as described at https://hub.docker.com/r/ibmcom/db2 + */ + public Db2Container acceptLicense() { + addEnv("LICENSE", "accept"); + return this; + } + + @Override + public String getDriverClassName() { + return "com.ibm.db2.jcc.DB2Driver"; + } + + @Override + public String getJdbcUrl() { + String additionalUrlParams = constructUrlParameters(":", ";", ";"); + return "jdbc:db2://" + getHost() + ":" + getMappedPort(DB2_PORT) + "/" + databaseName + additionalUrlParams; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getDatabaseName() { + return databaseName; + } + + @Override + public Db2Container withUsername(String username) { + this.username = username; + return this; + } + + @Override + public Db2Container withPassword(String password) { + this.password = password; + return this; + } + + @Override + public Db2Container withDatabaseName(String dbName) { + this.databaseName = dbName; + return this; + } + + @Override + protected void waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this); + } + + @Override + protected String getTestQueryString() { + return "SELECT 1 FROM SYSIBM.SYSDUMMY1"; + } +} diff --git a/modules/db2/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/db2/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 00000000000..d433ae9fc82 --- /dev/null +++ b/modules/db2/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.containers.Db2ContainerProvider diff --git a/modules/db2/src/test/java/org/testcontainers/Db2TestImages.java b/modules/db2/src/test/java/org/testcontainers/Db2TestImages.java new file mode 100644 index 00000000000..002d030b1c5 --- /dev/null +++ b/modules/db2/src/test/java/org/testcontainers/Db2TestImages.java @@ -0,0 +1,7 @@ +package org.testcontainers; + +import org.testcontainers.utility.DockerImageName; + +public interface Db2TestImages { + DockerImageName DB2_IMAGE = DockerImageName.parse("icr.io/db2_community/db2:11.5.8.0"); +} diff --git a/modules/db2/src/test/java/org/testcontainers/db2/Db2ContainerTest.java b/modules/db2/src/test/java/org/testcontainers/db2/Db2ContainerTest.java new file mode 100644 index 00000000000..6b17d1583f7 --- /dev/null +++ b/modules/db2/src/test/java/org/testcontainers/db2/Db2ContainerTest.java @@ -0,0 +1,61 @@ +package org.testcontainers.db2; + +import org.junit.jupiter.api.Test; +import org.testcontainers.Db2TestImages; +import org.testcontainers.db.AbstractContainerDatabaseTest; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +class Db2ContainerTest extends AbstractContainerDatabaseTest { + + @Test + void testSimple() throws SQLException { + try ( // container { + Db2Container db2 = new Db2Container("icr.io/db2_community/db2:11.5.8.0").acceptLicense() + // } + ) { + db2.start(); + + ResultSet resultSet = performQuery(db2, "SELECT 1 FROM SYSIBM.SYSDUMMY1"); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + assertHasCorrectExposedAndLivenessCheckPorts(db2); + } + } + + @Test + void testSimpleWithNewImage() throws SQLException { + try (Db2Container db2 = new Db2Container("icr.io/db2_community/db2:11.5.8.0").acceptLicense()) { + db2.start(); + + ResultSet resultSet = performQuery(db2, "SELECT 1 FROM SYSIBM.SYSDUMMY1"); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + assertHasCorrectExposedAndLivenessCheckPorts(db2); + } + } + + @Test + void testWithAdditionalUrlParamInJdbcUrl() { + try ( + Db2Container db2 = new Db2Container(Db2TestImages.DB2_IMAGE) + .withUrlParam("sslConnection", "false") + .acceptLicense() + ) { + db2.start(); + + String jdbcUrl = db2.getJdbcUrl(); + assertThat(jdbcUrl).contains(":sslConnection=false;"); + } + } + + private void assertHasCorrectExposedAndLivenessCheckPorts(Db2Container db2) { + assertThat(db2.getExposedPorts()).containsExactly(Db2Container.DB2_PORT); + assertThat(db2.getLivenessCheckPortNumbers()).containsExactly(db2.getMappedPort(Db2Container.DB2_PORT)); + } +} diff --git a/modules/db2/src/test/java/org/testcontainers/jdbc/db2/DB2JDBCDriverTest.java b/modules/db2/src/test/java/org/testcontainers/jdbc/db2/DB2JDBCDriverTest.java new file mode 100644 index 00000000000..cfa74c8017a --- /dev/null +++ b/modules/db2/src/test/java/org/testcontainers/jdbc/db2/DB2JDBCDriverTest.java @@ -0,0 +1,17 @@ +package org.testcontainers.jdbc.db2; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +class DB2JDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { // + { "jdbc:tc:db2://hostname/databasename", EnumSet.noneOf(Options.class) }, + } + ); + } +} diff --git a/modules/db2/src/test/resources/container-license-acceptance.txt b/modules/db2/src/test/resources/container-license-acceptance.txt new file mode 100644 index 00000000000..c4ad0a38410 --- /dev/null +++ b/modules/db2/src/test/resources/container-license-acceptance.txt @@ -0,0 +1 @@ +ibmcom/db2:11.5.0.0a diff --git a/modules/db2/src/test/resources/logback-test.xml b/modules/db2/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/db2/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/elasticsearch/build.gradle b/modules/elasticsearch/build.gradle new file mode 100644 index 00000000000..7f57916e991 --- /dev/null +++ b/modules/elasticsearch/build.gradle @@ -0,0 +1,8 @@ +description = "Testcontainers :: elasticsearch" + +dependencies { + api project(':testcontainers') + + testImplementation "org.elasticsearch.client:elasticsearch-rest-client:9.4.3" + testImplementation "org.elasticsearch.client:transport:7.17.29" +} diff --git a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java new file mode 100644 index 00000000000..2080d21649a --- /dev/null +++ b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java @@ -0,0 +1,310 @@ +package org.testcontainers.elasticsearch; + +import com.github.dockerjava.api.exception.NotFoundException; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.testcontainers.containers.BindMode; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; + +import java.io.ByteArrayInputStream; +import java.net.InetSocketAddress; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.util.Optional; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; + +/** + * Testcontainers implementation for Elasticsearch. + *

+ * Supported image: {@code docker.elastic.co/elasticsearch/elasticsearch}, {@code elasticsearch} + *

+ * Exposed ports: + *

    + *
  • HTTP: 9200
  • + *
  • TCP Transport: 9300
  • + *
+ */ +@Slf4j +public class ElasticsearchContainer extends GenericContainer { + + /** + * Elasticsearch Default Password for Elasticsearch >= 8 + */ + public static final String ELASTICSEARCH_DEFAULT_PASSWORD = "changeme"; + + /** + * Elasticsearch Default HTTP port + */ + private static final int ELASTICSEARCH_DEFAULT_PORT = 9200; + + /** + * Elasticsearch Default Transport port + * The TransportClient will be removed in Elasticsearch 8. No need to expose this port anymore in the future. + */ + @Deprecated + private static final int ELASTICSEARCH_DEFAULT_TCP_PORT = 9300; + + /** + * Elasticsearch Docker base image + */ + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "docker.elastic.co/elasticsearch/elasticsearch" + ); + + @Deprecated + private static final DockerImageName DEFAULT_OSS_IMAGE_NAME = DockerImageName.parse( + "docker.elastic.co/elasticsearch/elasticsearch-oss" + ); + + private static final DockerImageName ELASTICSEARCH_IMAGE_NAME = DockerImageName.parse("elasticsearch"); + + // default location of the automatically generated self-signed HTTP cert for versions >= 8 + private static final String DEFAULT_CERT_PATH = "/usr/share/elasticsearch/config/certs/http_ca.crt"; + + @Deprecated + private boolean isOss = false; + + private final boolean isAtLeastMajorVersion8; + + private String certPath = ""; + + /** + * Create an Elasticsearch Container by passing the full docker image name + * + * @param dockerImageName Full docker image name as a {@link String}, like: docker.elastic.co/elasticsearch/elasticsearch:7.9.2 + */ + public ElasticsearchContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + /** + * Create an Elasticsearch Container by passing the full docker image name + * + * @param dockerImageName Full docker image name as a {@link DockerImageName}, like: DockerImageName.parse("docker.elastic.co/elasticsearch/elasticsearch:7.9.2") + */ + public ElasticsearchContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, DEFAULT_OSS_IMAGE_NAME, ELASTICSEARCH_IMAGE_NAME); + + if (dockerImageName.isCompatibleWith(DEFAULT_OSS_IMAGE_NAME)) { + this.isOss = true; + log.warn( + "{} is not supported anymore after 7.10.2. Please switch to {}", + dockerImageName.getUnversionedPart(), + DEFAULT_IMAGE_NAME.getUnversionedPart() + ); + } + + withEnv("discovery.type", "single-node"); + // disable disk threshold checks + withEnv("cluster.routing.allocation.disk.threshold_enabled", "false"); + // Sets default memory of elasticsearch instance to 2GB + // Spaces are deliberate to allow user to define additional jvm options as elasticsearch resolves option files lexicographically + withClasspathResourceMapping( + "elasticsearch-default-memory-vm.options", + "/usr/share/elasticsearch/config/jvm.options.d/ elasticsearch-default-memory-vm.options", + BindMode.READ_ONLY + ); + addExposedPorts(ELASTICSEARCH_DEFAULT_PORT, ELASTICSEARCH_DEFAULT_TCP_PORT); + this.isAtLeastMajorVersion8 = + new ComparableVersion(dockerImageName.getVersionPart()).isGreaterThanOrEqualTo("8.0.0"); + // regex that + // matches 8.3 JSON logging with started message and some follow up content within the message field + // matches 8.0 JSON logging with no whitespace between message field and content + // matches 7.x JSON logging with whitespace between message field and content + // matches 6.x text logging with node name in brackets and just a 'started' message till the end of the line + String regex = ".*(\"message\":\\s?\"started[\\s?|\"].*|] started\n$)"; + setWaitStrategy(Wait.forLogMessage(regex, 1)); + if (isAtLeastMajorVersion8) { + withPassword(ELASTICSEARCH_DEFAULT_PASSWORD); + withCertPath(DEFAULT_CERT_PATH); + } + } + + /** + * If this is running above Elasticsearch 8, this will return the probably self-signed CA cert that has been extracted + * + * @return byte array optional containing the CA cert extracted from the docker container + */ + public Optional caCertAsBytes() { + if (StringUtils.isBlank(certPath)) { + return Optional.empty(); + } + try { + byte[] bytes = copyFileFromContainer(certPath, IOUtils::toByteArray); + if (bytes.length > 0) { + return Optional.of(bytes); + } + } catch (NotFoundException e) { + // just emit an error message, but do not throw an exception + // this might be ok, if the docker image is accidentally looking like version 8 or latest + // can happen if Elasticsearch is repackaged, i.e. with custom plugins + log.warn("CA cert under " + certPath + " not found."); + } + return Optional.empty(); + } + + /** + * A SSL context based on the self-signed CA, so that using this SSL Context allows to connect to the Elasticsearch service + * @return a customized SSL Context + */ + public SSLContext createSslContextFromCa() { + try { + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + Certificate trustedCa = factory.generateCertificate( + new ByteArrayInputStream( + caCertAsBytes() + .orElseThrow(() -> new IllegalStateException("CA cert under " + certPath + " not found.")) + ) + ); + KeyStore trustStore = KeyStore.getInstance("pkcs12"); + trustStore.load(null, null); + trustStore.setCertificateEntry("ca", trustedCa); + + final SSLContext sslContext = SSLContext.getInstance("TLSv1.3"); + TrustManagerFactory tmfactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmfactory.init(trustStore); + sslContext.init(null, tmfactory.getTrustManagers(), null); + return sslContext; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * Define the Elasticsearch password to set. It enables security behind the scene for major version below 8.0.0. + * It's not possible to use security with the oss image. + * @param password Password to set + * @return this + */ + public ElasticsearchContainer withPassword(String password) { + if (isOss) { + throw new IllegalArgumentException( + "You can not activate security on Elastic OSS Image. Please switch to the default distribution" + ); + } + withEnv("ELASTIC_PASSWORD", password); + if (!isAtLeastMajorVersion8) { + // major version 8 is secure by default and does not need this to enable authentication + withEnv("xpack.security.enabled", "true"); + } + return this; + } + + /** + * Configure a CA cert path that is not the default + * + * @param certPath Path to the CA certificate within the Docker container to extract it from after start up + * @return this + */ + public ElasticsearchContainer withCertPath(String certPath) { + this.certPath = certPath; + return this; + } + + String getCertPath() { + return certPath; + } + + public String getHttpHostAddress() { + return getHost() + ":" + getMappedPort(ELASTICSEARCH_DEFAULT_PORT); + } + + /** + * Checks env first if this implies HTTP/HTTPS. + * Otherwise, detects the scheme used by Elasticsearch using curl + * + * @return "http" or "https" + */ + String getHttpScheme() { + String securityEnabled = getEnvMap().get("xpack.security.enabled"); + String httpSslEnabled = getEnvMap().get("xpack.security.http.ssl.enabled"); + + // Respect explicit user config + if ("false".equalsIgnoreCase(securityEnabled) || "false".equalsIgnoreCase(httpSslEnabled)) { + return "http"; + } + if ("true".equalsIgnoreCase(httpSslEnabled)) { + return "https"; + } + + if (!isRunning()) { + throw new IllegalStateException( + "Cannot determine HTTP scheme: environment variables are not set and container is not running for curl probe" + ); + } + + ExecResult httpsResult = null; + ExecResult httpResult = null; + try { + // HTTPS probe: any HTTP response (200/401/403/...) => scheme is HTTPS. + // http_code == 000 means we didn't get an HTTP response (TLS/connect failure/timeout). + httpsResult = + execInContainer( + "curl", + "-sS", + "-k", + "--connect-timeout", + "2", + "--max-time", + "4", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "https://localhost:" + ELASTICSEARCH_DEFAULT_PORT + "/" + ); + if (httpsResult.getExitCode() == 0 && !"000".equals(httpsResult.getStdout().trim())) { + return "https"; + } + + // HTTP probe + httpResult = + execInContainer( + "curl", + "-sS", + "--connect-timeout", + "2", + "--max-time", + "4", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "http://localhost:" + ELASTICSEARCH_DEFAULT_PORT + "/" + ); + if (httpResult.getExitCode() == 0 && !"000".equals(httpResult.getStdout().trim())) { + return "http"; + } + } catch (Exception e) { + throw new RuntimeException("Failed to detect protocol via curl", e); + } + + throw new RuntimeException( + String.format( + "Failed to detect protocol via curl. Both HTTPS and HTTP probes failed. " + + "HTTPS probe - exit code: %d, stdout: %s, stderr: %s; " + + "HTTP probe - exit code: %d, stdout: %s, stderr: %s", + httpsResult.getExitCode(), + httpsResult.getStdout(), + httpsResult.getStderr(), + httpResult.getExitCode(), + httpResult.getStdout(), + httpResult.getStderr() + ) + ); + } + + // The TransportClient will be removed in Elasticsearch 8. No need to expose this port anymore in the future. + @Deprecated + public InetSocketAddress getTcpHost() { + return new InetSocketAddress(getHost(), getMappedPort(ELASTICSEARCH_DEFAULT_TCP_PORT)); + } +} diff --git a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/KibanaContainer.java b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/KibanaContainer.java new file mode 100644 index 00000000000..ee8ce714854 --- /dev/null +++ b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/KibanaContainer.java @@ -0,0 +1,669 @@ +package org.testcontainers.elasticsearch; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.ContainerNetwork; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.rnorth.ducttape.unreliables.Unreliables; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.Base58; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * Testcontainers implementation for Kibana. + * Minimum supported version: {@value MINIMUM_SUPPORTED_VERSION} + *

+ * Supports two modes: + *

    + *
  • Managed mode: Kibana automatically connects to an {@link ElasticsearchContainer}. + * See KibanaContainerTest#managedModeCanStartAndReachElasticsearchInSameExplicitNetwork()
  • + *
  • External mode: Kibana connects to an external Elasticsearch instance via URL. + * See KibanaContainerTest#externalModeCanWorkWithUsernamePassword()
  • + *
+ *

+ */ +public class KibanaContainer extends GenericContainer { + + private static final String ES_CA_CERT_PATH = "/usr/share/kibana/config/certs/es-ca.crt"; + + private static final Logger log = LoggerFactory.getLogger(KibanaContainer.class); + + private static final int KIBANA_DEFAULT_PORT = 5601; + + private static final String KIBANA_SYSTEM_USER = "kibana_system"; + + private static final String MINIMUM_SUPPORTED_VERSION = "8.0.0"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("docker.elastic.co/kibana/kibana"); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private String encryptionKey; + + private ElasticsearchContainer elasticsearch; + + private String elasticsearchUrl; + + private String elasticsearchUsername; + + private String elasticsearchPassword; + + private String elasticsearchServiceAccountToken; + + private byte[] elasticsearchCaCertificate; + + private Duration startupTimeout = Duration.ofSeconds(120); + + /** + * If KibanaContainer creates an ad-hoc shared network (managed mode, neither container has an explicit network), + * it owns closing it. + */ + private Network createdSharedNetwork; + + /** + * Creates a KibanaContainer in managed mode. + * Kibana automatically connects to the provided Elasticsearch container. + * + * @param elasticsearch the Elasticsearch container to connect to + */ + public KibanaContainer(ElasticsearchContainer elasticsearch) { + this(buildDockerImageName(elasticsearch)); + this.elasticsearch = elasticsearch; + dependsOn(elasticsearch); + } + + /** + * Creates a KibanaContainer in external mode. + * Use {@link #withElasticsearchUrl(String)} to configure the Elasticsearch connection. Use other methods to provide security credentials and such. + * + * @param dockerImageName the Docker image name + */ + public KibanaContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + /** + * Creates a KibanaContainer in external mode. + * Use {@link #withElasticsearchUrl(String)} to configure the Elasticsearch connection. Use other methods to provide security credentials and such. + * + * @param dockerImageName the Docker image name + */ + public KibanaContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + ensureCompatibleVersion(dockerImageName.getVersionPart()); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + this.encryptionKey = stableConfigKey(dockerImageName); + + withExposedPorts(KIBANA_DEFAULT_PORT); + //we have to explicitly set wait the strategy later on in configure, once we know the security configuration + setWaitStrategy(null); + } + + /** + * Sets the encryption key used for Kibana's encrypted saved objects. + * The key must be at least 32 characters. When not set, a deterministic default derived from + * the image name is used, which is required for {@link #withReuse(boolean)} to work correctly. + * + * @param encryptionKey the encryption key + * @return this container instance + */ + public KibanaContainer withEncryptionKey(String encryptionKey) { + if (encryptionKey == null || encryptionKey.length() < 32) { + throw new IllegalArgumentException("Kibana encryption key must be at least 32 characters long"); + } + this.encryptionKey = encryptionKey; + return this; + } + + /** + * Enables or disables container reuse across JVM runs. + * + *

Supported in external mode only. When Kibana is configured via + * {@link #withElasticsearchUrl(String)}, the container configuration is fully deterministic + * and TC can reliably locate the running container on subsequent runs. + * + *

Reuse is not supported in managed mode (i.e. when this container was created with + * an {@link ElasticsearchContainer}). Managed mode introduces several non-deterministic inputs + * into the container hash on every run (ad-hoc network ID, random network alias, fresh service + * account token), so TC always sees a different hash and starts a fresh container instead of + * reusing the existing one. This is a framework-level characteristic that affects any container + * connected via {@code withNetwork()} — not specific to {@code KibanaContainer}. + * + * @param reusable whether to enable container reuse + * @return this container instance + * @throws IllegalStateException if {@code reusable} is {@code true} and managed mode is active + */ + @Override + public KibanaContainer withReuse(boolean reusable) { + if (reusable && elasticsearch != null) { + throw new IllegalStateException( + "withReuse(true) is not supported for KibanaContainer in managed mode. " + + "Use external mode (withElasticsearchUrl) to enable reuse." + ); + } + return super.withReuse(reusable); + } + + /** + * Configures the Elasticsearch URL for external mode. + * + * @param elasticsearchUrl the Elasticsearch URL (e.g., "https://my.fancy.setup.elastic.cloud:9200") + * @return this container instance + * @throws IllegalStateException if already using managed mode + */ + public KibanaContainer withElasticsearchUrl(String elasticsearchUrl) { + if (elasticsearch != null) { + throw new IllegalStateException("Cannot set Elasticsearch URL when using Elasticsearch container"); + } + this.elasticsearchUrl = elasticsearchUrl; + return this; + } + + /** + * Configures Kibana to authenticate using the kibana_system user. + * + * @param password the password for the kibana_system user + * @return this container instance + */ + public KibanaContainer withKibanaSystemPassword(String password) { + return withKibanaUsernameAndPassword(KIBANA_SYSTEM_USER, password); + } + + /** + * Configures credentials Kibana will use for authentication. + * + * @param username the Elasticsearch username (cannot be 'elastic') + * @param password the password + * @return this container instance + * @throws IllegalStateException if a service account token is already configured + * @throws IllegalArgumentException if credentials are invalid + */ + public KibanaContainer withKibanaUsernameAndPassword(String username, String password) { + if (elasticsearchServiceAccountToken != null) { + throw new IllegalStateException( + "Conflicting Elasticsearch credentials: provide either a service account token " + + "or a username/password pair, not both." + ); + } + if (StringUtils.isAnyBlank(username, password)) { + throw new IllegalArgumentException("Kibana credentials cannot be blank"); + } + if (!username.equals(username.trim()) || !password.equals(password.trim())) { + throw new IllegalArgumentException("Kibana credentials cannot have leading or trailing whitespace"); + } + if ("elastic".equals(username)) { + throw new IllegalArgumentException("Username 'elastic' is reserved for internal use by Elasticsearch"); + } + + this.elasticsearchUsername = username; + this.elasticsearchPassword = password; + return this; + } + + /** + * Configures a service account token for Elasticsearch authentication. + * + * @param token the service account token + * @return this container instance + * @throws IllegalStateException if username/password credentials are already configured + * @throws IllegalArgumentException if token is blank + */ + public KibanaContainer withElasticsearchServiceAccountToken(String token) { + if (elasticsearchUsername != null) { + throw new IllegalStateException( + "Conflicting Elasticsearch credentials: provide either a service account token " + + "or a username/password pair, not both." + ); + } + if (StringUtils.isBlank(token)) { + throw new IllegalArgumentException("Service account token cannot be empty"); + } + + if (!token.equals(token.trim())) { + throw new IllegalArgumentException("Service token cannot have leading or trailing whitespace"); + } + this.elasticsearchServiceAccountToken = token; + return this; + } + + /** + * Configures the Elasticsearch CA certificate for HTTPS connections. + * + * @param caCertificate the CA certificate in PEM format + * @return this container instance + * @throws IllegalArgumentException if certificate is empty + */ + public KibanaContainer withElasticsearchCaCertificate(byte[] caCertificate) { + if (caCertificate == null || caCertificate.length == 0) { + throw new IllegalArgumentException("Elasticsearch CA certificate cannot be empty"); + } + this.elasticsearchCaCertificate = caCertificate; + return this; + } + + @Override + protected void configure() { + super.configure(); + + addEnv("XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY", encryptionKey); + addEnv("SERVER_NAME", "kibana"); + + if (elasticsearchCaCertificate != null) { + withCopyToContainer(Transferable.of(elasticsearchCaCertificate), ES_CA_CERT_PATH); + addEnv("ELASTICSEARCH_SSL_CERTIFICATEAUTHORITIES", ES_CA_CERT_PATH); + } + if (elasticsearch != null) { + configureManagedElasticsearch(); + } else if (elasticsearchUrl != null) { + configureExternalElasticsearch(); + } else { + throw new IllegalStateException( + "Elasticsearch must be configured either via constructor KibanaContainer(elasticsearch) " + + "or via .withElasticsearchUrl() for external Elasticsearch" + ); + } + //wait strategy is set in configure, because we don't know the security configuration before + configureWaitStrategy(); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + super.containerIsStarted(containerInfo); + log.info("Kibana is now ready, it can be accessed at http://{}", getHttpHostAddress()); + } + + @Override + public void stop() { + super.stop(); + if (createdSharedNetwork != null) { + try { + createdSharedNetwork.close(); + } catch (Exception e) { + log.debug("Failed to close shared network", e); + } finally { + createdSharedNetwork = null; + } + } + } + + @Override + public KibanaContainer withStartupTimeout(Duration startupTimeout) { + this.startupTimeout = startupTimeout; + return this; + } + + private static DockerImageName buildDockerImageName(ElasticsearchContainer elasticsearch) { + String esVersion = DockerImageName.parse(elasticsearch.getDockerImageName()).getVersionPart(); + ensureCompatibleVersion(esVersion); + return DEFAULT_IMAGE_NAME.withTag(esVersion); + } + + private void configureExternalElasticsearch() { + addEnv("ELASTICSEARCH_HOSTS", elasticsearchUrl); + if (elasticsearchServiceAccountToken != null) { + addEnv("ELASTICSEARCH_SERVICEACCOUNTTOKEN", elasticsearchServiceAccountToken); + } else if (elasticsearchUsername != null && elasticsearchPassword != null) { + addEnv("ELASTICSEARCH_USERNAME", elasticsearchUsername); + addEnv("ELASTICSEARCH_PASSWORD", elasticsearchPassword); + } else { + log.info( + "No Elasticsearch credentials provided for external mode; Kibana will attempt to connect anonymously" + ); + } + } + + private void configureManagedElasticsearch() { + ensureCorrectNetworkSetupForManagedMode(); + + if (getNetwork() == null) { + createAdHocNetwork(); + } + + String protocol = elasticsearch.getHttpScheme(); + + String hosts = protocol + "://" + resolveExistingEsDnsNameOnNetwork(getNetwork()) + ":9200"; + addEnv("ELASTICSEARCH_HOSTS", hosts); + + if ("https".equals(protocol)) { + // In managed mode, if Elasticsearch uses HTTPS we must configure Kibana with the ES CA, unless provided by user + if (this.elasticsearchCaCertificate == null) { + byte[] ca = copyElasticsearchHttpCaCertificateOrThrow(); + + withCopyToContainer(Transferable.of(ca), ES_CA_CERT_PATH); + addEnv("ELASTICSEARCH_SSL_CERTIFICATEAUTHORITIES", ES_CA_CERT_PATH); + } + if (elasticsearch != null && !getEnvMap().containsKey("ELASTICSEARCH_SSL_VERIFICATIONMODE")) { + addEnv("ELASTICSEARCH_SSL_VERIFICATIONMODE", "certificate"); + } + } + + // Elasticsearch 8.x+ has the security enabled by default, so lack of the env var set to false means security is enabled + boolean securityDisabled = "false".equalsIgnoreCase(elasticsearch.getEnvMap().get("xpack.security.enabled")); + + if (!securityDisabled) { + // Managed mode: authenticate Kibana -> Elasticsearch using a Kibana service account token. + // This avoids any password lifecycle management for kibana_system. + String token = createKibanaServiceAccountToken(protocol); + addEnv("ELASTICSEARCH_SERVICEACCOUNTTOKEN", token); + } + } + + private byte[] copyElasticsearchHttpCaCertificateOrThrow() { + try { + return elasticsearch.copyFileFromContainer(elasticsearch.getCertPath(), IOUtils::toByteArray); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to copy Elasticsearch HTTP CA certificate from '" + + elasticsearch.getCertPath() + + "'. " + + "In managed HTTPS mode, KibanaContainer requires access to the Elasticsearch HTTP CA.", + e + ); + } + } + + private void ensureCorrectNetworkSetupForManagedMode() { + Network esNetwork = elasticsearch.getNetwork(); + Network kbNetwork = this.getNetwork(); + + if ((esNetwork == null) != (kbNetwork == null)) { + throw new IllegalStateException( + "Managed mode requires either both containers share the same explicit network, " + + "or neither specifies a network (KibanaContainer will create one). " + ); + } + + // Both explicit: must be same + if (esNetwork != kbNetwork) { + throw new IllegalStateException( + "Elasticsearch and Kibana have different networks configured. " + + "In managed mode both containers must share the same explicit network instance, " + + "or neither must define a network." + ); + } + } + + private void createAdHocNetwork() { + // Fully managed: create ad-hoc network and own it. + createdSharedNetwork = Network.newNetwork(); + withNetwork(createdSharedNetwork); + + // Managed-mode safety rule: by the time Kibana is configuring itself, Elasticsearch must already be + // started (via dependsOn) + String esId = requireElasticsearchContainerId(); + + // Elasticsearch is already created/started. Attach it to the ad-hoc network. + // We don't need to provide an explicit alias - we'll use the container name for DNS resolution. + // Equivalent of https://docs.docker.com/reference/cli/docker/network/connect/ + connectRunningContainerToNetwork(esId, createdSharedNetwork); + } + + private String resolveExistingEsDnsNameOnNetwork(Network network) { + String esId = requireElasticsearchContainerId(); + + InspectContainerResponse info = DockerClientFactory.instance().client().inspectContainerCmd(esId).exec(); + + Map networks = info.getNetworkSettings().getNetworks(); + if (networks == null) { + throw new IllegalStateException("Elasticsearch container has no network configuration"); + } + + // Try to find the network endpoint - Docker may key by network name or ID + ContainerNetwork endpoint = findNetworkEndpoint(networks, network); + if (endpoint == null) { + throw new IllegalStateException( + "Elasticsearch container is not connected to the expected network. " + + "Ensure both containers use the same Network instance." + ); + } + + // Prefer user-defined network aliases (skip Testcontainers auto-generated tc-* aliases) + if (endpoint.getAliases() != null && !endpoint.getAliases().isEmpty()) { + for (String alias : endpoint.getAliases()) { + if (StringUtils.isNotBlank(alias)) { + String cleaned = alias.trim(); + // Skip Testcontainers auto-generated aliases (tc-*), prefer user-defined ones + if (!cleaned.startsWith("tc-")) { + log.info("Using Elasticsearch network alias: {}", cleaned); + return cleaned; + } + } + } + } + + // Fallback: use container name + String containerName = info.getName(); + if (containerName != null && !containerName.trim().isEmpty()) { + String dnsName = containerName.replaceFirst("^/", "").trim(); + log.info("No user-defined network alias found, using Elasticsearch container name: {}", dnsName); + return dnsName; + } + + throw new IllegalStateException( + "Cannot determine Elasticsearch DNS name. " + + "When using a custom network, set a network alias on the Elasticsearch container." + ); + } + + private ContainerNetwork findNetworkEndpoint(Map networks, Network network) { + // Try network ID first + ContainerNetwork endpoint = networks.get(network.getId()); + if (endpoint != null) { + return endpoint; + } + + // Try network name as fallback (Docker may key by name instead of ID) + try { + String networkName = DockerClientFactory + .instance() + .client() + .inspectNetworkCmd() + .withNetworkId(network.getId()) + .exec() + .getName(); + if (networkName != null) { + return networks.get(networkName); + } + } catch (Exception e) { + // Ignore and return null + } + + return null; + } + + private String requireElasticsearchContainerId() { + String id = elasticsearch.getContainerId(); + if (StringUtils.isBlank(id)) { + throw new IllegalStateException( + "Elasticsearch containerId is not available. In managed mode, Elasticsearch must be started via dependsOn(elasticsearch) " + + "before KibanaContainer is started." + ); + } + return id.trim(); + } + + private void connectRunningContainerToNetwork(String containerId, Network network) { + String networkId = network.getId(); + + try { + DockerClientFactory + .instance() + .client() + .connectToNetworkCmd() + .withContainerId(containerId) + .withNetworkId(networkId) + .exec(); + } catch (Exception e) { + throw new IllegalStateException("Failed to connect Elasticsearch container to ad-hoc shared network", e); + } + } + + private static void ensureCompatibleVersion(String esVersion) { + ComparableVersion comparableVersion = new ComparableVersion(esVersion); + if (comparableVersion.isLessThan(MINIMUM_SUPPORTED_VERSION)) { + throw new IllegalArgumentException( + String.format( + "Kibana version %s is not supported. Minimum version is %s", + comparableVersion, + MINIMUM_SUPPORTED_VERSION + ) + ); + } + } + + private String createKibanaServiceAccountToken(String protocol) { + if (elasticsearch == null) { + throw new IllegalStateException("Cannot create service account token in external mode"); + } + + String elasticPassword = elasticsearch + .getEnvMap() + .getOrDefault("ELASTIC_PASSWORD", ElasticsearchContainer.ELASTICSEARCH_DEFAULT_PASSWORD); + + // Create a unique token name to avoid collisions if the same ES container is reused. + String tokenName = "tc-kibana-" + Base58.randomString(12); + + String endpoint = protocol + "://localhost:9200/_security/service/elastic/kibana/credential/token/" + tokenName; + + return Unreliables.retryUntilSuccess( + 45, + TimeUnit.SECONDS, + () -> { + String curlTlsArgs = ""; + if ("https".equals(protocol)) { + // In managed HTTPS mode, use the Elasticsearch HTTP CA for curl. + curlTlsArgs = " --cacert '" + elasticsearch.getCertPath() + "'"; + } + + String curlCommand = String.format( + "curl -sS%s -u \"elastic:$1\" -H 'Content-Type: application/json' -X POST '%s'", + curlTlsArgs, + endpoint + ); + + Container.ExecResult result = elasticsearch.execInContainer( + "/bin/sh", + "-c", + curlCommand, + "sh", + elasticPassword + ); + + String stdout = (result.getStdout() == null) ? "" : result.getStdout(); + String stderr = (result.getStderr() == null) ? "" : result.getStderr(); + + if (result.getExitCode() != 0) { + throw new RuntimeException( + "Failed to create Kibana service account token. Exit code: " + + result.getExitCode() + + ", stdout: " + + stdout + + ", stderr: " + + stderr + ); + } + + JsonNode json = OBJECT_MAPPER.readTree(stdout); + JsonNode value = json.path("token").path("value"); + if (value.isTextual() && !value.asText().trim().isEmpty()) { + return value.asText().trim(); + } + + throw new RuntimeException("Service account token response did not contain token.value: " + stdout); + } + ); + } + + private static String stableConfigKey(DockerImageName imageName) { + // UUID v3 (name-based) gives a deterministic 32-character string from the image name, + // keeping xpack.encryptedSavedObjects.encryptionKey identical across JVM runs — + // a prerequisite for container reuse. Call withEncryptionKey() for real secret management. + return UUID + .nameUUIDFromBytes(imageName.asCanonicalNameString().getBytes(StandardCharsets.UTF_8)) + .toString() + .replace("-", ""); + } + + /** + * Returns the HTTP host address for accessing Kibana. + * + * @return the host address in the format "host:port" + */ + public String getHttpHostAddress() { + return getHost() + ":" + getMappedPort(KIBANA_DEFAULT_PORT); + } + + private void configureWaitStrategy() { + if (this.getWaitStrategy() != null) { + // the user might have set a custom wait strategy + return; + } + HttpWaitStrategy strategy = Wait + .forHttp("/api/status") + .forPort(KIBANA_DEFAULT_PORT) + .forStatusCode(200) + .forResponsePredicate(this::isKibanaReady); + + // Add authentication if we have Elasticsearch credentials available + String serviceToken = getEnvMap().get("ELASTICSEARCH_SERVICEACCOUNTTOKEN"); + String username = getEnvMap().get("ELASTICSEARCH_USERNAME"); + String password = getEnvMap().get("ELASTICSEARCH_PASSWORD"); + + if (serviceToken != null) { + strategy = strategy.withHeader("Authorization", "Bearer " + serviceToken); + } else if (username != null && password != null) { + strategy = strategy.withBasicCredentials(username, password); + } + + setWaitStrategy(strategy.withStartupTimeout(this.startupTimeout)); + } + + private boolean isKibanaReady(String body) { + try { + JsonNode json = OBJECT_MAPPER.readTree(body); + JsonNode status = json.path("status"); + + String overallLevel = status.path("overall").path("level").asText(null); + String elasticsearchLevel = status.path("core").path("elasticsearch").path("level").asText(null); + String savedObjectsLevel = status.path("core").path("savedObjects").path("level").asText(null); + + boolean overallAvailable = "available".equalsIgnoreCase(overallLevel); + boolean elasticsearchAvailable = "available".equalsIgnoreCase(elasticsearchLevel); + boolean savedObjectsAvailable = "available".equalsIgnoreCase(savedObjectsLevel); + + boolean isReady = overallAvailable && elasticsearchAvailable && savedObjectsAvailable; + + if (log.isDebugEnabled()) { + log.debug( + "Kibana status check: READY={} (overall={}, elasticsearch={}, savedObjects={})", + isReady, + overallLevel, + elasticsearchLevel, + savedObjectsLevel + ); + } + + return isReady; + } catch (Exception e) { + log.debug("Kibana status check: FAILED to parse response - {}", e.getMessage()); + return false; + } + } +} diff --git a/modules/elasticsearch/src/main/resources/elasticsearch-default-memory-vm.options b/modules/elasticsearch/src/main/resources/elasticsearch-default-memory-vm.options new file mode 100644 index 00000000000..62b4e57c6e1 --- /dev/null +++ b/modules/elasticsearch/src/main/resources/elasticsearch-default-memory-vm.options @@ -0,0 +1,3 @@ +-Xms2147483648 +-Xmx2147483648 +-Dingest.geoip.downloader.enabled.default=false diff --git a/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java b/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java new file mode 100644 index 00000000000..52cd78b4a34 --- /dev/null +++ b/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java @@ -0,0 +1,638 @@ +package org.testcontainers.elasticsearch; + +import com.github.dockerjava.api.DockerClient; +import org.apache.http.HttpHost; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.util.EntityUtils; +import org.elasticsearch.action.admin.cluster.health.ClusterHealthResponse; +import org.elasticsearch.client.Request; +import org.elasticsearch.client.Response; +import org.elasticsearch.client.ResponseException; +import org.elasticsearch.client.RestClient; +import org.elasticsearch.client.transport.TransportClient; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.transport.TransportAddress; +import org.elasticsearch.transport.client.PreBuiltTransportClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.BindMode; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.RemoteDockerImage; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.IOException; + +import javax.net.ssl.SSLHandshakeException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +class ElasticsearchContainerTest { + + /** + * Elasticsearch version which should be used for the Tests + */ + private static final String ELASTICSEARCH_VERSION = "7.9.2"; + + private static final DockerImageName ELASTICSEARCH_IMAGE = DockerImageName + .parse("docker.elastic.co/elasticsearch/elasticsearch") + .withTag(ELASTICSEARCH_VERSION); + + /** + * Elasticsearch default username, when secured + */ + private static final String ELASTICSEARCH_USERNAME = "elastic"; + + /** + * From 6.8, we can optionally activate security with a default password. + */ + private static final String ELASTICSEARCH_PASSWORD = "changeme"; + + private RestClient client = null; + + private RestClient anonymousClient = null; + + @AfterEach + public void stopRestClient() throws IOException { + if (client != null) { + client.close(); + client = null; + } + if (anonymousClient != null) { + anonymousClient.close(); + anonymousClient = null; + } + } + + @SuppressWarnings("deprecation") // Using deprecated constructor for verification of backwards compatibility + @Test + @Deprecated // We will remove this test in the future + void elasticsearchDeprecatedCtorTest() throws IOException { + // Create the elasticsearch container. + try ( + ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE).withEnv("foo", "bar") // dummy env for compiler checking correct generics usage + ) { + // Start the container. This step might take some time... + container.start(); + + // Do whatever you want with the rest client ... + Response response = getClient(container).performRequest(new Request("GET", "/")); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + assertThat(EntityUtils.toString(response.getEntity())).contains(ELASTICSEARCH_VERSION); + + // The default image is running with the features under Elastic License + response = getClient(container).performRequest(new Request("GET", "/_xpack/")); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + // For now we test that we have the monitoring feature available + assertThat(EntityUtils.toString(response.getEntity())).contains("monitoring"); + } + } + + @Test + void elasticsearchDefaultTest() throws IOException { + // Create the elasticsearch container. + try ( + ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE).withEnv("foo", "bar") // dummy env for compiler checking correct generics usage + ) { + // Start the container. This step might take some time... + container.start(); + + // Do whatever you want with the rest client ... + Response response = getClient(container).performRequest(new Request("GET", "/")); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + assertThat(EntityUtils.toString(response.getEntity())).contains(ELASTICSEARCH_VERSION); + + // The default image is running with the features under Elastic License + response = getClient(container).performRequest(new Request("GET", "/_xpack/")); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + // For now we test that we have the monitoring feature available + assertThat(EntityUtils.toString(response.getEntity())).contains("monitoring"); + } + } + + @Test + void elasticsearchSecuredTest() throws IOException { + try ( + ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE) + .withPassword(ELASTICSEARCH_PASSWORD) + ) { + container.start(); + + // The cluster should be secured so it must fail when we try to access / without credentials + assertThat(catchThrowable(() -> getAnonymousClient(container).performRequest(new Request("GET", "/")))) + .as("We should not be able to access / URI with an anonymous client.") + .isInstanceOf(ResponseException.class); + + // But it should work when we try to access / with the proper login and password + Response response = getClient(container).performRequest(new Request("GET", "/")); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + assertThat(EntityUtils.toString(response.getEntity())).contains(ELASTICSEARCH_VERSION); + } + } + + @Test + void elasticsearchVersion() throws IOException { + try (ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE)) { + container.start(); + Response response = getClient(container).performRequest(new Request("GET", "/")); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + String responseAsString = EntityUtils.toString(response.getEntity()); + assertThat(responseAsString).contains(ELASTICSEARCH_VERSION); + } + } + + @Test + void elasticsearchVersion83() throws IOException { + try ( + ElasticsearchContainer container = new ElasticsearchContainer( + "docker.elastic.co/elasticsearch/elasticsearch:8.3.0" + ) + ) { + container.start(); + Response response = getClient(container).performRequest(new Request("GET", "/")); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + assertThat(EntityUtils.toString(response.getEntity())).contains("8.3.0"); + } + } + + @Test + void elasticsearchOssImage() throws IOException { + try ( + // ossContainer { + ElasticsearchContainer container = new ElasticsearchContainer( + "docker.elastic.co/elasticsearch/elasticsearch-oss:7.10.2" + ) + // } + ) { + container.start(); + Response response = getClient(container).performRequest(new Request("GET", "/")); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + // The OSS image does not have any feature under Elastic License + assertThat(catchThrowable(() -> getClient(container).performRequest(new Request("GET", "/_xpack/")))) + .as("We should not have /_xpack endpoint with an OSS License") + .isInstanceOf(ResponseException.class); + } + } + + @Test + void restClientClusterHealth() throws IOException { + // httpClientContainer7 { + // Create the elasticsearch container. + try (ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE)) { + // Start the container. This step might take some time... + container.start(); + + // Do whatever you want with the rest client ... + final CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials( + AuthScope.ANY, + new UsernamePasswordCredentials(ELASTICSEARCH_USERNAME, ELASTICSEARCH_PASSWORD) + ); + + client = + RestClient + .builder(HttpHost.create(container.getHttpHostAddress())) + .setHttpClientConfigCallback(httpClientBuilder -> { + return httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + }) + .build(); + + Response response = client.performRequest(new Request("GET", "/_cluster/health")); + // }} + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + assertThat(EntityUtils.toString(response.getEntity())).contains("cluster_name"); + // httpClientContainer7 {{ + } + // } + } + + @Test + void restClientClusterHealthElasticsearch8() throws IOException { + // httpClientContainer8 { + // Create the elasticsearch container. + try ( + ElasticsearchContainer container = new ElasticsearchContainer( + "docker.elastic.co/elasticsearch/elasticsearch:8.1.2" + ) + ) { + // Start the container. This step might take some time... + container.start(); + + // Do whatever you want with the rest client ... + final CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials( + AuthScope.ANY, + new UsernamePasswordCredentials(ELASTICSEARCH_USERNAME, ELASTICSEARCH_PASSWORD) + ); + + client = + RestClient + // use HTTPS for Elasticsearch 8 + .builder(HttpHost.create("https://" + container.getHttpHostAddress())) + .setHttpClientConfigCallback(httpClientBuilder -> { + httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + // SSL is activated by default in Elasticsearch 8 + httpClientBuilder.setSSLContext(container.createSslContextFromCa()); + return httpClientBuilder; + }) + .build(); + + Response response = client.performRequest(new Request("GET", "/_cluster/health")); + // }} + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + assertThat(EntityUtils.toString(response.getEntity())).contains("cluster_name"); + // httpClientContainer8 {{ + } + // } + } + + @Test + void restClientClusterHealthElasticsearch8WithoutSSL() throws IOException { + // httpClientContainerNoSSL8 { + // Create the elasticsearch container. + try ( + ElasticsearchContainer container = new ElasticsearchContainer( + "docker.elastic.co/elasticsearch/elasticsearch:8.1.2" + ) + // disable SSL + .withEnv("xpack.security.transport.ssl.enabled", "false") + .withEnv("xpack.security.http.ssl.enabled", "false") + ) { + // Start the container. This step might take some time... + container.start(); + + // Do whatever you want with the rest client ... + final CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials( + AuthScope.ANY, + new UsernamePasswordCredentials(ELASTICSEARCH_USERNAME, ELASTICSEARCH_PASSWORD) + ); + + client = + RestClient + .builder(HttpHost.create(container.getHttpHostAddress())) + .setHttpClientConfigCallback(httpClientBuilder -> { + return httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + }) + .build(); + + Response response = client.performRequest(new Request("GET", "/_cluster/health")); + // }} + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + assertThat(EntityUtils.toString(response.getEntity())).contains("cluster_name"); + // httpClientContainerNoSSL8 {{ + } + // } + } + + @Test + void restClientSecuredClusterHealth() throws IOException { + // httpClientSecuredContainer { + // Create the elasticsearch container. + try ( + ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE) + // With a password + .withPassword(ELASTICSEARCH_PASSWORD) + ) { + // Start the container. This step might take some time... + container.start(); + + // Create the secured client. + final CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials( + AuthScope.ANY, + new UsernamePasswordCredentials(ELASTICSEARCH_USERNAME, ELASTICSEARCH_PASSWORD) + ); + + client = + RestClient + .builder(HttpHost.create(container.getHttpHostAddress())) + .setHttpClientConfigCallback(httpClientBuilder -> { + return httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + }) + .build(); + + Response response = client.performRequest(new Request("GET", "/_cluster/health")); + // }} + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + assertThat(EntityUtils.toString(response.getEntity())).contains("cluster_name"); + // httpClientSecuredContainer {{ + } + // } + } + + @SuppressWarnings("deprecation") // The TransportClient will be removed in Elasticsearch 8. + @Test + void transportClientClusterHealth() { + // transportClientContainer { + // Create the elasticsearch container. + try (ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE)) { + // Start the container. This step might take some time... + container.start(); + + // Do whatever you want with the transport client + TransportAddress transportAddress = new TransportAddress(container.getTcpHost()); + String expectedClusterName = "docker-cluster"; + Settings settings = Settings.builder().put("cluster.name", expectedClusterName).build(); + try ( + TransportClient transportClient = new PreBuiltTransportClient(settings) + .addTransportAddress(transportAddress) + ) { + ClusterHealthResponse healths = transportClient.admin().cluster().prepareHealth().get(); + String clusterName = healths.getClusterName(); + // }}} + assertThat(clusterName).isEqualTo(expectedClusterName); + // transportClientContainer {{{ + } + } + // } + } + + @Test + void incompatibleSettingsTest() { + // The OSS image can not use security feature + assertThat( + catchThrowable(() -> { + new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch-oss:7.10.2") + .withPassword("foo"); + }) + ) + .as("We should not be able to activate security with an OSS License") + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testDockerHubElasticsearch8ImageSecureByDefault() throws Exception { + try (ElasticsearchContainer container = new ElasticsearchContainer("elasticsearch:8.1.2")) { + container.start(); + + assertClusterHealthResponse(container); + } + } + + @Test + void testElasticsearch8SecureByDefaultCustomCaCertFails() throws Exception { + final MountableFile mountableFile = MountableFile.forClasspathResource("http_ca.crt"); + String caPath = "/tmp/http_ca.crt"; + try ( + ElasticsearchContainer container = new ElasticsearchContainer( + "docker.elastic.co/elasticsearch/elasticsearch:8.1.2" + ) + .withCopyToContainer(mountableFile, caPath) + .withCertPath(caPath) + ) { + container.start(); + + // this is expected, as a different cert is used for creating the SSL context + assertThat(catchThrowable(() -> getClusterHealth(container))) + .as( + "PKIX path validation failed: java.security.cert.CertPathValidatorException: Path does not chain with any of the trust anchors" + ) + .isInstanceOf(SSLHandshakeException.class); + } + } + + @Test + void testElasticsearch8SecureByDefaultHttpWaitStrategy() throws Exception { + final HttpWaitStrategy httpsWaitStrategy = Wait + .forHttps("/") + .forPort(9200) + .forStatusCode(200) + .withBasicCredentials(ELASTICSEARCH_USERNAME, ELASTICSEARCH_PASSWORD) + // trusting self-signed certificate + .allowInsecure(); + + try ( + ElasticsearchContainer container = new ElasticsearchContainer( + "docker.elastic.co/elasticsearch/elasticsearch:8.1.2" + ) + .waitingFor(httpsWaitStrategy) + ) { + // Start the container. This step might take some time... + container.start(); + + assertClusterHealthResponse(container); + } + } + + @Test + void testElasticsearch8SecureByDefaultFailsSilentlyOnLatestImages() throws Exception { + // this test exists for custom images by users that use the `latest` tag + // even though the version might be older than version 8 + // this tags an old 7.x version as :latest + tagImage("docker.elastic.co/elasticsearch/elasticsearch:7.9.2", "elasticsearch-tc-older-release", "latest"); + DockerImageName image = DockerImageName + .parse("elasticsearch-tc-older-release:latest") + .asCompatibleSubstituteFor("docker.elastic.co/elasticsearch/elasticsearch"); + + try (ElasticsearchContainer container = new ElasticsearchContainer(image)) { + container.start(); + + Response response = getClient(container).performRequest(new Request("GET", "/_cluster/health")); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + assertThat(EntityUtils.toString(response.getEntity())).contains("cluster_name"); + } + } + + @Test + void testElasticsearch7CanHaveSecurityEnabledAndUseSslContext() throws Exception { + String customizedCertPath = "/usr/share/elasticsearch/config/certs/http_ca_customized.crt"; + try ( + ElasticsearchContainer container = new ElasticsearchContainer( + "docker.elastic.co/elasticsearch/elasticsearch:7.17.15" + ) + .withPassword(ElasticsearchContainer.ELASTICSEARCH_DEFAULT_PASSWORD) + .withEnv("xpack.security.enabled", "true") + .withEnv("xpack.security.http.ssl.enabled", "true") + .withEnv("xpack.security.http.ssl.key", "/usr/share/elasticsearch/config/certs/elasticsearch.key") + .withEnv( + "xpack.security.http.ssl.certificate", + "/usr/share/elasticsearch/config/certs/elasticsearch.crt" + ) + .withEnv("xpack.security.http.ssl.certificate_authorities", customizedCertPath) + // these lines show how certificates can be created self-made way + // obviously this shouldn't be done in prod environment, where proper and officially signed keys should be present + .withCopyToContainer( + Transferable.of( + "#!/bin/bash\n" + + "mkdir -p /usr/share/elasticsearch/config/certs;" + + "openssl req -x509 -newkey rsa:4096 -keyout /usr/share/elasticsearch/config/certs/elasticsearch.key -out /usr/share/elasticsearch/config/certs/elasticsearch.crt -days 365 -nodes -subj \"/CN=localhost\";" + + "openssl x509 -outform der -in /usr/share/elasticsearch/config/certs/elasticsearch.crt -out " + + customizedCertPath + + "; chown -R elasticsearch /usr/share/elasticsearch/config/certs/", + 555 + ), + "/usr/share/elasticsearch/generate-certs.sh" + ) + // because we need to generate the certificates before Elasticsearch starts, the entry command has to be tuned accordingly + .withCommand( + "sh", + "-c", + "/usr/share/elasticsearch/generate-certs.sh && /usr/local/bin/docker-entrypoint.sh" + ) + .withCertPath(customizedCertPath) + ) { + container.start(); + assertClusterHealthResponse(container); + } + } + + @Test + void testElasticsearchDefaultMaxHeapSize() throws Exception { + long defaultHeapSize = 2147483648L; + + try (ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE)) { + container.start(); + assertElasticsearchContainerHasHeapSize(container, defaultHeapSize); + } + } + + @Test + void testElasticsearchCustomMaxHeapSizeInEnvironmentVariable() throws Exception { + long customHeapSize = 1574961152; + + try ( + ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE) + .withEnv("ES_JAVA_OPTS", String.format("-Xms%d -Xmx%d", customHeapSize, customHeapSize)) + ) { + container.start(); + assertElasticsearchContainerHasHeapSize(container, customHeapSize); + } + } + + @Test + void testElasticsearchCustomMaxHeapSizeInJvmOptionsFile() throws Exception { + long customHeapSize = 1574961152; + + try ( + ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE) + .withClasspathResourceMapping( + "test-custom-memory-jvm.options", + "/usr/share/elasticsearch/config/jvm.options.d/a-user-defined-jvm.options", + BindMode.READ_ONLY + ); + ) { + container.start(); + assertElasticsearchContainerHasHeapSize(container, customHeapSize); + } + } + + private void tagImage(String sourceImage, String targetImage, String targetTag) throws InterruptedException { + DockerClient dockerClient = DockerClientFactory.instance().client(); + dockerClient + .tagImageCmd(new RemoteDockerImage(DockerImageName.parse(sourceImage)).get(), targetImage, targetTag) + .exec(); + } + + private Response getClusterHealth(ElasticsearchContainer container) throws IOException { + // Create the secured client. + final CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials( + AuthScope.ANY, + new UsernamePasswordCredentials( + ELASTICSEARCH_USERNAME, + ElasticsearchContainer.ELASTICSEARCH_DEFAULT_PASSWORD + ) + ); + + client = + RestClient + .builder(HttpHost.create("https://" + container.getHttpHostAddress())) + .setHttpClientConfigCallback(httpClientBuilder -> { + httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + httpClientBuilder.setSSLContext(container.createSslContextFromCa()); + return httpClientBuilder; + }) + .build(); + + return client.performRequest(new Request("GET", "/_cluster/health")); + } + + private RestClient getClient(ElasticsearchContainer container) { + if (client == null) { + final CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials( + AuthScope.ANY, + new UsernamePasswordCredentials(ELASTICSEARCH_USERNAME, ELASTICSEARCH_PASSWORD) + ); + + String protocol = container.caCertAsBytes().isPresent() ? "https://" : "http://"; + + client = + RestClient + .builder(HttpHost.create(protocol + container.getHttpHostAddress())) + .setHttpClientConfigCallback(httpClientBuilder -> { + if (container.caCertAsBytes().isPresent()) { + httpClientBuilder.setSSLContext(container.createSslContextFromCa()); + } + return httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + }) + .build(); + } + + return client; + } + + private RestClient getAnonymousClient(ElasticsearchContainer container) { + if (anonymousClient == null) { + anonymousClient = RestClient.builder(HttpHost.create(container.getHttpHostAddress())).build(); + } + + return anonymousClient; + } + + private void assertElasticsearchContainerHasHeapSize(ElasticsearchContainer container, long heapSizeInBytes) + throws Exception { + Response response = getClient(container).performRequest(new Request("GET", "/_nodes/_all/jvm")); + String responseBody = EntityUtils.toString(response.getEntity()); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + assertThat(responseBody).contains("\"heap_init_in_bytes\":" + heapSizeInBytes); + assertThat(responseBody).contains("\"heap_max_in_bytes\":" + heapSizeInBytes); + } + + private void assertClusterHealthResponse(ElasticsearchContainer container) throws IOException { + Response response = getClusterHealth(container); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + assertThat(EntityUtils.toString(response.getEntity())).contains("cluster_name"); + } + + @Test + void testGetHttpSchemeForElasticsearch7ReturnsHttp() { + try (ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE)) { + container.start(); + assertThat(container.getHttpScheme()).isEqualTo("http"); + } + } + + @Test + void testGetHttpSchemeForElasticsearch8ReturnsHttps() { + try ( + ElasticsearchContainer container = new ElasticsearchContainer( + "docker.elastic.co/elasticsearch/elasticsearch:8.1.2" + ) + ) { + container.start(); + assertThat(container.getHttpScheme()).isEqualTo("https"); + } + } + + @Test + void testGetHttpSchemeForElasticsearch8WithSslDisabledReturnsHttp() { + try ( + ElasticsearchContainer container = new ElasticsearchContainer( + "docker.elastic.co/elasticsearch/elasticsearch:8.1.2" + ) + .withEnv("xpack.security.http.ssl.enabled", "false") + ) { + container.start(); + assertThat(container.getHttpScheme()).isEqualTo("http"); + } + } +} diff --git a/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java b/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java new file mode 100644 index 00000000000..0099b5613ca --- /dev/null +++ b/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/KibanaContainerTest.java @@ -0,0 +1,515 @@ +package org.testcontainers.elasticsearch; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.HttpResponse; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.util.EntityUtils; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.testcontainers.Testcontainers; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.TestcontainersConfiguration; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +class KibanaContainerTest { + + public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String ES_IMAGE = "docker.elastic.co/elasticsearch/elasticsearch:9.2.4"; + + @Test + void cannotCreateKibanaContainerForVersionLessThan8() { + Assertions + .assertThatThrownBy(() -> new KibanaContainer("docker.elastic.co/kibana/kibana:7.17.29")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("is not supported"); + } + + @Test + void managedModeCanStartAndReachElasticsearchInSameExplicitNetwork() throws IOException { + // managedModeCanStartAndReachElasticsearchInSameExplicitNetwork { + try ( + Network network = Network.newNetwork(); + ElasticsearchContainer es = new ElasticsearchContainer(ES_IMAGE).withNetwork(network); + KibanaContainer kibana = new KibanaContainer(es).withNetwork(network) + ) { + es.start(); + kibana.start(); + + String status = getKibanaStatus(kibana); + Assertions.assertThat(status).isEqualTo("available"); + } + // } + } + + @Test + void managedModeCannotStartWithOnlyESNetworkExplicit() { + Network network = Network.newNetwork(); + try ( + ElasticsearchContainer es = new ElasticsearchContainer(ES_IMAGE).withNetwork(network); + KibanaContainer kibana = new KibanaContainer(es) + ) { + Assertions + .assertThatThrownBy(kibana::start) + .isInstanceOf(ContainerLaunchException.class) + .satisfies(ex -> { + Assertions + .assertThat(ex.getCause()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("explicit network"); + }); + } + } + + @Test + void managedModeCannotStartWithDifferentExplicitNetworks() { + try ( + Network esNetwork = Network.newNetwork(); + Network kibanaNetwork = Network.newNetwork(); + ElasticsearchContainer es = new ElasticsearchContainer(ES_IMAGE).withNetwork(esNetwork); + KibanaContainer kibana = new KibanaContainer(es).withNetwork(kibanaNetwork) + ) { + Assertions + .assertThatThrownBy(kibana::start) + .isInstanceOf(ContainerLaunchException.class) + .satisfies(ex -> { + Assertions + .assertThat(ex.getCause()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("different networks"); + }); + } + } + + @Test + void managedModeUsesCustomNetworkAliasInExplicitNetwork() throws Exception { + final String customEsAlias = "my-custom-es-alias"; + + try ( + Network network = Network.newNetwork(); + ElasticsearchContainer es = new ElasticsearchContainer(ES_IMAGE) + .withNetwork(network) + .withNetworkAliases(customEsAlias); + KibanaContainer kibana = new KibanaContainer(es).withNetwork(network) + ) { + kibana.start(); + + Assertions.assertThat(kibana.isRunning()).isTrue(); + + // Verify Kibana uses the custom alias (not auto-generated tc-* alias) + Container.ExecResult result = kibana.execInContainer("sh", "-c", "env | grep ELASTICSEARCH_HOSTS"); + + Assertions.assertThat(result.getStdout()).contains(customEsAlias).contains(":9200"); + } + } + + @Test + void managedModeCanStartAndReachElasticsearchWithoutExplicitNetwork() throws IOException { + try ( + ElasticsearchContainer es = new ElasticsearchContainer(ES_IMAGE); + KibanaContainer kibana = new KibanaContainer(es) + ) { + kibana.start(); + + String status = getKibanaStatus(kibana); + Assertions.assertThat(status).isEqualTo("available"); + } + } + + @Test + void managedModeCanStartWithoutElasticsearchSecurity() throws IOException { + try ( + ElasticsearchContainer es = new ElasticsearchContainer(ES_IMAGE).withEnv("xpack.security.enabled", "false"); + KibanaContainer kibana = new KibanaContainer(es) + ) { + kibana.start(); + + String status = getKibanaStatus(kibana); + Assertions.assertThat(status).isEqualTo("available"); + } + } + + @Test + void managedModeCanStartWithoutElasticsearchHttps() throws IOException { + try ( + ElasticsearchContainer es = new ElasticsearchContainer(ES_IMAGE) + .withEnv("xpack.security.enabled", "true") + .withEnv("xpack.security.http.ssl.enabled", "false"); + KibanaContainer kibana = new KibanaContainer(es) + ) { + es.start(); + kibana.start(); + + String status = getKibanaStatus(kibana); + Assertions.assertThat(status).isEqualTo("available"); + } + } + + @Test + void externalModeFailsWithConflictingCredentials() { + Assertions + .assertThatThrownBy(() -> { + new KibanaContainer("docker.elastic.co/kibana/kibana:8.0.0") + .withKibanaUsernameAndPassword("user", "pass") + .withElasticsearchServiceAccountToken("token"); + }) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Conflicting Elasticsearch credentials"); + } + + @Test + void managedModeFailsWhenSettingElasticsearchUrl() { + ElasticsearchContainer es = new ElasticsearchContainer(ES_IMAGE); + Assertions + .assertThatThrownBy(() -> { + new KibanaContainer(es).withElasticsearchUrl("http://somewhere.over.the.rainbow:9200"); + }) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot set Elasticsearch URL when using Elasticsearch container"); + } + + @Test + void failsWhenNoElasticsearchConfigured() { + try (KibanaContainer kibana = new KibanaContainer("docker.elastic.co/kibana/kibana:8.0.0")) { + Assertions + .assertThatThrownBy(kibana::start) + .isInstanceOf(ContainerLaunchException.class) + .satisfies(ex -> { + Assertions + .assertThat(ex.getCause()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Elasticsearch must be configured"); + }); + } + } + + @Test + void externalModeCanWorkWithUsernamePassword() throws IOException, InterruptedException { + final String esHostname = "elasticsearch"; + + // externalModeCanWorkWithUsernamePassword { + try ( + Network network = Network.newNetwork(); + ElasticsearchContainer es = new ElasticsearchContainer(ES_IMAGE) + .withNetwork(network) + .withNetworkAliases(esHostname) + .withEnv("xpack.security.http.ssl.enabled", "false") + ) { + es.start(); + String kibanaSystemPassword = setKibanaSystemPassword(es); + + try ( + KibanaContainer kibana = new KibanaContainer("docker.elastic.co/kibana/kibana:9.2.2") //this minor version is intentionally below ES version + .withNetwork(network) + .withElasticsearchUrl("http://" + esHostname + ":9200") + .withKibanaSystemPassword(kibanaSystemPassword) + ) { + kibana.start(); + String status = getKibanaStatus(kibana); + Assertions.assertThat(status).isEqualTo("available"); + } + } + // } + } + + @Test + void externalModeCanWorkWithoutCredentials() throws IOException { + final String esHostname = "elasticsearch"; + + try ( + Network network = Network.newNetwork(); + ElasticsearchContainer es = new ElasticsearchContainer(ES_IMAGE) + .withNetwork(network) + .withNetworkAliases(esHostname) + .withEnv("xpack.security.enabled", "false") + .withEnv("xpack.security.http.ssl.enabled", "false"); + KibanaContainer kibana = new KibanaContainer("docker.elastic.co/kibana/kibana:9.2.4") + .withNetwork(network) + .withElasticsearchUrl("http://" + esHostname + ":9200") + ) { + es.start(); + kibana.start(); + String status = getKibanaStatus(kibana); + Assertions.assertThat(status).isEqualTo("available"); + } + } + + @Test + void externalModeCanStartAndReachElasticsearchWithCertAndServiceToken() throws Exception { + byte[] caCrt; + byte[] nodeCrt; + byte[] nodeKey; + + String esHostname = "elasticsearch"; + + String instancesYml = + "instances:\n" + + " - name: es01\n" + + " dns: [ \"localhost\", \"" + + esHostname + + "\", \"es01\" ]\n" + + " ip: [ \"127.0.0.1\" ]\n"; + + try ( + ElasticsearchContainer setup = new ElasticsearchContainer(ES_IMAGE) + .withEnv("discovery.type", "single-node") + .withCopyToContainer( + Transferable.of(instancesYml.getBytes(StandardCharsets.UTF_8), 0644), + "/tmp/instances.yml" + ) + ) { + setup.start(); + + // Run certutil inside the running container and write outputs inside the container FS + Container.ExecResult execResult = setup.execInContainer( + "bash", + "-lc", + "set -euo pipefail && " + + "mkdir -p /tmp/out && " + + "cd /usr/share/elasticsearch && " + + "bin/elasticsearch-certutil ca --silent --pem --out /tmp/out/ca.zip && " + + "unzip -o /tmp/out/ca.zip -d /tmp/out && " + + "bin/elasticsearch-certutil cert --silent --pem --in /tmp/instances.yml --ca-cert /tmp/out/ca/ca.crt --ca-key /tmp/out/ca/ca.key --out /tmp/out/certs.zip && " + + "unzip -o /tmp/out/certs.zip -d /tmp/out" + ); + Assertions.assertThat(execResult.getExitCode()).isEqualTo(0); + + // copy the certificates and key from the container, so we can use them later + caCrt = setup.copyFileFromContainer("/tmp/out/ca/ca.crt", IOUtils::toByteArray); + nodeCrt = setup.copyFileFromContainer("/tmp/out/es01/es01.crt", IOUtils::toByteArray); + nodeKey = setup.copyFileFromContainer("/tmp/out/es01/es01.key", IOUtils::toByteArray); + } + + try ( + Network network = Network.newNetwork(); + ElasticsearchContainer es = new ElasticsearchContainer( + "docker.elastic.co/elasticsearch/elasticsearch:9.2.4" + ) + .withNetwork(network) + .withNetworkAliases(esHostname) + ) { + applyTls(es, caCrt, nodeCrt, nodeKey); + es.start(); + String kibanaServiceAccountToken = createKibanaServiceAccountToken(es); + + try ( + KibanaContainer kibana = new KibanaContainer("docker.elastic.co/kibana/kibana:9.2.4") + // network is needed only because the ES we try to access via explicit mode is operated by non-public Docker + .withNetwork(network) + .withElasticsearchUrl("https://" + esHostname + ":9200") + .withElasticsearchServiceAccountToken(kibanaServiceAccountToken) + .withElasticsearchCaCertificate(es.caCertAsBytes().get()) + ) { + kibana.start(); + String status = getKibanaStatus(kibana); + Assertions.assertThat(status).isEqualTo("available"); + } + } + } + + private static String setKibanaSystemPassword(ElasticsearchContainer elasticsearch) throws IOException { + String kibanaPassword = "kibana-system-" + System.currentTimeMillis(); + + try (CloseableHttpClient httpClient = createHttpClient(elasticsearch)) { + String url = String.format( + "%s://%s/_security/user/kibana_system/_password", + elasticsearch.getHttpScheme(), + elasticsearch.getHttpHostAddress() + ); + HttpPost request = new HttpPost(url); + request.setHeader("Content-Type", "application/json"); + request.setEntity(new StringEntity("{\"password\":\"" + kibanaPassword + "\"}")); + + HttpResponse response = httpClient.execute(request); + int statusCode = response.getStatusLine().getStatusCode(); + String body = EntityUtils.toString(response.getEntity()); + + if (statusCode != 200) { + throw new IllegalStateException( + "Failed to set kibana_system password. HTTP " + statusCode + ", body=" + body + ); + } + + // ES 9.x returns {} on success; older versions may return {"acknowledged":true} + // Just validate that the body is valid JSON. + try { + OBJECT_MAPPER.readTree(body.isEmpty() ? "{}" : body); + } catch (IOException e) { + throw new IllegalStateException("Non-JSON response body: " + body, e); + } + + return kibanaPassword; + } + } + + private static String createKibanaServiceAccountToken(ElasticsearchContainer elasticsearch) throws IOException { + String tokenName = "kibana-token-" + System.currentTimeMillis(); + + try (CloseableHttpClient httpClient = createHttpClient(elasticsearch)) { + String url = String.format( + "%s://%s/_security/service/elastic/kibana/credential/token/%s", + elasticsearch.getHttpScheme(), + elasticsearch.getHttpHostAddress(), + tokenName + ); + HttpPost request = new HttpPost(url); + request.setHeader("Content-Type", "application/json"); + + HttpResponse response = httpClient.execute(request); + int statusCode = response.getStatusLine().getStatusCode(); + String body = EntityUtils.toString(response.getEntity()); + + if (statusCode != 200) { + throw new IllegalStateException( + "Failed to create Kibana service account token. HTTP " + statusCode + ", body=" + body + ); + } + + // Expected JSON: + // {"created":true,"token":{"name":"...","value":"AAEAA..."}} + try { + JsonNode root = OBJECT_MAPPER.readTree(body); + JsonNode tokenValue = root.path("token").path("value"); + + if (tokenValue.isMissingNode() || tokenValue.isNull()) { + throw new IllegalStateException("Token value not found in response: " + body); + } + + return tokenValue.asText(); + } catch (IOException e) { + throw new IllegalStateException("Failed to parse token response: " + body, e); + } + } + } + + private static CloseableHttpClient createHttpClient(ElasticsearchContainer elasticsearch) { + String elasticPassword = elasticsearch.getEnvMap().get("ELASTIC_PASSWORD"); + HttpClientBuilder clientBuilder = HttpClientBuilder.create(); + + if (StringUtils.isNotBlank(elasticPassword)) { + CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials( + AuthScope.ANY, + new UsernamePasswordCredentials("elastic", elasticPassword) + ); + clientBuilder.setDefaultCredentialsProvider(credentialsProvider); + } + + String scheme = elasticsearch.getHttpScheme(); + if ("https".equals(scheme)) { + SSLConnectionSocketFactory sslSocketFactory = new SSLConnectionSocketFactory( + elasticsearch.createSslContextFromCa() + ); + clientBuilder.setSSLSocketFactory(sslSocketFactory); + } + + return clientBuilder.build(); + } + + private static String getKibanaStatus(KibanaContainer kibana) throws IOException { + try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { + String url = "http://" + kibana.getHttpHostAddress() + "/api/status"; + HttpResponse response = httpClient.execute(new org.apache.http.client.methods.HttpGet(url)); + int statusCode = response.getStatusLine().getStatusCode(); + String body = EntityUtils.toString(response.getEntity()); + + if (statusCode != 200) { + throw new IllegalStateException("Failed to get Kibana status. HTTP " + statusCode + ", body=" + body); + } + + JsonNode json = OBJECT_MAPPER.readTree(body); + String status = json.path("status").path("overall").path("level").asText(null); + if (status == null) { + throw new IllegalStateException("Kibana status response missing 'status.overall.level' field: " + body); + } + return status; + } + } + + @Test + void withReuseShouldReuseTheSameContainer() { + Assumptions + .assumeThat(TestcontainersConfiguration.getInstance().environmentSupportsReuse()) + .as("testcontainers.reuse.enable must be true") + .isTrue(); + + final String kibanaImage = "docker.elastic.co/kibana/kibana:9.2.4"; + + // Testcontainers.exposeHostPorts + host.testcontainers.internal lets Kibana reach ES + // from inside the container on any platform (Linux Docker Engine included). + // No withNetwork() on Kibana keeps the hash fully deterministic: + // - no dynamic network ID + // - the random tc-* alias added by GenericContainer's constructor is only serialised + // into the CreateContainerCmd when withNetwork() has been called, so it is absent here + // The host.testcontainers.internal extra-host IP is the same for kibana1 and kibana2 + // because they start in the same JVM (same PortForwardingContainer instance). + // The first Kibana container must stay running while the second one starts, because + // withReuse(true) only skips JVM-shutdown cleanup — an explicit stop() still removes the + // container, so there would be nothing to find. + try ( + ElasticsearchContainer es = new ElasticsearchContainer(ES_IMAGE) + .withEnv("xpack.security.enabled", "false") + .withEnv("xpack.security.http.ssl.enabled", "false") + ) { + es.start(); + int esMappedPort = es.getMappedPort(9200); + Testcontainers.exposeHostPorts(esMappedPort); + String esUrl = "http://" + GenericContainer.INTERNAL_HOST_HOSTNAME + ":" + esMappedPort; + + KibanaContainer kibana1 = new KibanaContainer(kibanaImage).withElasticsearchUrl(esUrl).withReuse(true); + KibanaContainer kibana2 = new KibanaContainer(kibanaImage).withElasticsearchUrl(esUrl).withReuse(true); + + try { + kibana1.start(); + // kibana2 is started while kibana1 is still running; the reuse mechanism should + // find kibana1's container by hash and return the same container ID. + kibana2.start(); + + Assertions + .assertThat(kibana2.getContainerId()) + .as("KibanaContainer with withReuse(true) should reuse the same container on subsequent starts") + .isEqualTo(kibana1.getContainerId()); + } finally { + kibana1.stop(); + kibana2.stop(); + } + } + } + + private static void applyTls(ElasticsearchContainer c, byte[] caCrt, byte[] nodeCrt, byte[] nodeKey) { + final String certDir = "/usr/share/elasticsearch/config/certs"; + + // Copy provided materials + c.withCopyToContainer(Transferable.of(caCrt, 0644), certDir + "/http_ca.crt"); + c.withCopyToContainer(Transferable.of(nodeCrt, 0644), certDir + "/http.crt"); + c.withCopyToContainer(Transferable.of(nodeKey, 0644), certDir + "/http.key"); + + // Disable ES bootstrap TLS autoconfiguration + c.withEnv("xpack.security.autoconfiguration.enabled", "false"); + c.withEnv("xpack.security.enabled", "true"); + + // Configure ONLY HTTP TLS using exactly the provided files + c.withEnv("xpack.security.http.ssl.enabled", "true"); + c.withEnv("xpack.security.http.ssl.certificate_authorities", "certs/http_ca.crt"); + c.withEnv("xpack.security.http.ssl.certificate", "certs/http.crt"); + c.withEnv("xpack.security.http.ssl.key", "certs/http.key"); + } +} diff --git a/modules/elasticsearch/src/test/resources/http_ca.crt b/modules/elasticsearch/src/test/resources/http_ca.crt new file mode 100644 index 00000000000..704e5a37aa6 --- /dev/null +++ b/modules/elasticsearch/src/test/resources/http_ca.crt @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFWjCCA0KgAwIBAgIVAPVLz0Dwvzl66ZVpyJY/ntos+qQZMA0GCSqGSIb3DQEB +CwUAMDwxOjA4BgNVBAMTMUVsYXN0aWNzZWFyY2ggc2VjdXJpdHkgYXV0by1jb25m +aWd1cmF0aW9uIEhUVFAgQ0EwHhcNMjIwNDE0MDcyOTA4WhcNMjUwNDEzMDcyOTA4 +WjA8MTowOAYDVQQDEzFFbGFzdGljc2VhcmNoIHNlY3VyaXR5IGF1dG8tY29uZmln +dXJhdGlvbiBIVFRQIENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA +ufHmvM04dPKNF6AulX3HrjNfcYy62pBO2oJIKhScetDzuRupv/qiXkO1gzGT3/jk +8uk57rzylDFoCgFNUyWdYQAXD+qsy5vbAytGVNCHOGSuWlNm1bbDYwZNTXjZTK6C +CKIY31lbFn9a4oM+Jp1kvIr8GSMQAYDisq+yrVloDLkRs1SPImnoaXsq8epxloBf +En0vo5V8PtOh+xQFpPP21pd5QohCSB+jMaxaizScWX+k7BijEaS1LCsc2w2PNvwn +/bEvtW7w9w+HnzRGZW2nVlt8eji1PHMmfM5Zugn4HAxsSvdI9VRFGAeT2moNiSLN +zxOWmEvQVl2MxRWiTkM1EM7CFDN40hLHtHej8UddeXTDDXyLoUjY3FmaB45rLKdE +k/rszepc3lmptEMh74iUowKaYZTS5jRqT0yIDzevP5je3JP+pe4aNkx7lWMhTReQ +6fs97nd71PfJBuMcHPEA14zwSzSRb/8mNqqaQLBb5H1DpDcZtzbBxb4wFSAa7Dd0 +pVl4A04iB4PS3DaWg/im2C5a83nUTld7Lvy+I6cO1MTaXdkzn6EWXtxkHj7P4VXX +sFTr+Z2A6g/novqderirQzq8aD87MBp2hLBgG59lVB3IXA+CJTzBRBZipU+FOSs2 +1enMlaEa84d8cb+GSpDkmsvamPMhhLeMw6QLmhQXoWMCAwEAAaNTMFEwHQYDVR0O +BBYEFOt9JC+RiqfFdGf/UT2vfmnV8f+kMB8GA1UdIwQYMBaAFOt9JC+RiqfFdGf/ +UT2vfmnV8f+kMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBALFX +r2CKtu2DcLFQaZft9LeRK67xU0rjN8w1+0MN9otSkU9avd8atPOI9p6r67HZyoTv +LDA47ajitzPo4zAXiy4GUXFVCjx3iPOQ4TTCm0YEf9IudLHLqGTbK+Pup6XlfAMb +RejkztcXkxw3Cy6SjIRq99xU8J6Y1jAggB162hqnp3u42nA1PgOJgo/biUYeVtu7 +Y01gKPdCEkiqmSFxfLiRPv5Z4WyYoKge6UyDYFHu0zyMY7zQ2hzrPMFsEhI4+g5D +W7ihilcOijhvfeeWIxcP5lRn2pGbf2GtwqtA7Bt9YKp+NQIBPzK7D1ymS0v/CAWh +3Bv1rqkqDK8TlBybZitTTB6MgGtXOBccouTPmBFBXSWydvW4GW6Dag/ogHHE2vG2 +xlXY6EC1QEzExcM5FZNJI6SOaK0nl+WKAv060U/1ZqcRIkhyctYdkrK4449n1JMy +wjtwcDW7QxhQspHp8GEXztLctokqGjnuMcgPjVoFdiF3w/IV0UUvVeFK4Oms0YbH +uFr3q44Fu/Fol68/1CUk1ytgLUS5anf0Q0WlJsmMUX156ATA29dVBfloJN63EYd7 +01uwbjoMJce7MiwTaLIetW75fxxZHlQK9TMNhaQwKUO8SRaNuE4wKURFoKPg/Dqu +yPhx9adseStlJ3oV6ziEWMwjOK2JmJf0bmIqQ7KR +-----END CERTIFICATE----- diff --git a/modules/elasticsearch/src/test/resources/logback-test.xml b/modules/elasticsearch/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/elasticsearch/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/elasticsearch/src/test/resources/test-custom-memory-jvm.options b/modules/elasticsearch/src/test/resources/test-custom-memory-jvm.options new file mode 100644 index 00000000000..e3fe586284e --- /dev/null +++ b/modules/elasticsearch/src/test/resources/test-custom-memory-jvm.options @@ -0,0 +1,2 @@ +-Xms1574961152 +-Xmx1574961152 diff --git a/modules/gcloud/build.gradle b/modules/gcloud/build.gradle new file mode 100644 index 00000000000..3a020bdb759 --- /dev/null +++ b/modules/gcloud/build.gradle @@ -0,0 +1,13 @@ +description = "Testcontainers :: GCloud" + +dependencies { + api project(':testcontainers') + + testImplementation platform("com.google.cloud:libraries-bom:26.84.0") + testImplementation 'com.google.cloud:google-cloud-bigquery' + testImplementation 'com.google.cloud:google-cloud-datastore' + testImplementation 'com.google.cloud:google-cloud-firestore' + testImplementation 'com.google.cloud:google-cloud-pubsub' + testImplementation 'com.google.cloud:google-cloud-spanner' + testImplementation 'com.google.cloud:google-cloud-bigtable' +} diff --git a/modules/gcloud/src/main/java/org/testcontainers/containers/BigQueryEmulatorContainer.java b/modules/gcloud/src/main/java/org/testcontainers/containers/BigQueryEmulatorContainer.java new file mode 100644 index 00000000000..6590c6cab5b --- /dev/null +++ b/modules/gcloud/src/main/java/org/testcontainers/containers/BigQueryEmulatorContainer.java @@ -0,0 +1,46 @@ +package org.testcontainers.containers; + +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for BigQuery. + *

+ * Supported image: {@code ghcr.io/goccy/bigquery-emulator} + *

+ * + * @deprecated use {@link org.testcontainers.gcloud.BigQueryEmulatorContainer} instead. + */ +@Deprecated +public class BigQueryEmulatorContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("ghcr.io/goccy/bigquery-emulator"); + + private static final int HTTP_PORT = 9050; + + private static final int GRPC_PORT = 9060; + + private static final String PROJECT_ID = "test-project"; + + public BigQueryEmulatorContainer(String image) { + this(DockerImageName.parse(image)); + } + + public BigQueryEmulatorContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + addExposedPorts(HTTP_PORT, GRPC_PORT); + withCommand("--project", PROJECT_ID); + } + + public String getEmulatorHttpEndpoint() { + return String.format("http://%s:%d", getHost(), getMappedPort(HTTP_PORT)); + } + + public Integer getEmulatorGrpcPort() { + return getMappedPort(GRPC_PORT); + } + + public String getProjectId() { + return PROJECT_ID; + } +} diff --git a/modules/gcloud/src/main/java/org/testcontainers/containers/BigtableEmulatorContainer.java b/modules/gcloud/src/main/java/org/testcontainers/containers/BigtableEmulatorContainer.java new file mode 100644 index 00000000000..288cd499e37 --- /dev/null +++ b/modules/gcloud/src/main/java/org/testcontainers/containers/BigtableEmulatorContainer.java @@ -0,0 +1,55 @@ +package org.testcontainers.containers; + +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * A Bigtable container that relies in google cloud sdk. + *

+ * Supported images: {@code gcr.io/google.com/cloudsdktool/google-cloud-cli}, {@code gcr.io/google.com/cloudsdktool/cloud-sdk} + *

+ * Default port is 9000. + * + * @deprecated use {@link org.testcontainers.gcloud.BigtableEmulatorContainer} instead. + */ +@Deprecated +public class BigtableEmulatorContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/google-cloud-cli" + ); + + private static final DockerImageName CLOUD_SDK_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/cloud-sdk" + ); + + private static final String CMD = "gcloud beta emulators bigtable start --host-port 0.0.0.0:9000"; + + private static final int PORT = 9000; + + public BigtableEmulatorContainer(String image) { + this(DockerImageName.parse(image)); + } + + public BigtableEmulatorContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, CLOUD_SDK_IMAGE_NAME); + + withExposedPorts(PORT); + setWaitStrategy(Wait.forLogMessage(".*running.*$", 1)); + withCommand("/bin/sh", "-c", CMD); + } + + /** + * @return a host:port pair corresponding to the address on which the emulator is + * reachable from the test host machine. Directly usable as a parameter to the + * com.google.cloud.ServiceOptions.Builder#setHost(java.lang.String) method. + */ + public String getEmulatorEndpoint() { + return getHost() + ":" + getEmulatorPort(); + } + + public int getEmulatorPort() { + return getMappedPort(PORT); + } +} diff --git a/modules/gcloud/src/main/java/org/testcontainers/containers/DatastoreEmulatorContainer.java b/modules/gcloud/src/main/java/org/testcontainers/containers/DatastoreEmulatorContainer.java new file mode 100644 index 00000000000..cbe27e303f4 --- /dev/null +++ b/modules/gcloud/src/main/java/org/testcontainers/containers/DatastoreEmulatorContainer.java @@ -0,0 +1,75 @@ +package org.testcontainers.containers; + +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * A Datastore container that relies in google cloud sdk. + *

+ * Supported images: {@code gcr.io/google.com/cloudsdktool/google-cloud-cli}, {@code gcr.io/google.com/cloudsdktool/cloud-sdk} + *

+ * Default port is 8081. + * + * @deprecated use {@link org.testcontainers.gcloud.DatastoreEmulatorContainer} instead. + */ +@Deprecated +public class DatastoreEmulatorContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/google-cloud-cli" + ); + + private static final DockerImageName CLOUD_SDK_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/cloud-sdk" + ); + + private static final String PROJECT_ID = "test-project"; + + private static final String CMD = String.format( + "gcloud beta emulators datastore start --project %s --host-port 0.0.0.0:8081", + PROJECT_ID + ); + + private static final int HTTP_PORT = 8081; + + private String flags; + + public DatastoreEmulatorContainer(final String image) { + this(DockerImageName.parse(image)); + } + + public DatastoreEmulatorContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, CLOUD_SDK_IMAGE_NAME); + + withExposedPorts(HTTP_PORT); + setWaitStrategy(Wait.forHttp("/").forStatusCode(200)); + } + + @Override + protected void configure() { + String command = CMD; + if (this.flags != null && !this.flags.isEmpty()) { + command += " " + this.flags; + } + withCommand("/bin/sh", "-c", command); + } + + public DatastoreEmulatorContainer withFlags(String flags) { + this.flags = flags; + return this; + } + + /** + * @return a host:port pair corresponding to the address on which the emulator is + * reachable from the test host machine. Directly usable as a parameter to the + * com.google.cloud.ServiceOptions.Builder#setHost(java.lang.String) method. + */ + public String getEmulatorEndpoint() { + return getHost() + ":" + getMappedPort(HTTP_PORT); + } + + public String getProjectId() { + return PROJECT_ID; + } +} diff --git a/modules/gcloud/src/main/java/org/testcontainers/containers/FirestoreEmulatorContainer.java b/modules/gcloud/src/main/java/org/testcontainers/containers/FirestoreEmulatorContainer.java new file mode 100644 index 00000000000..3c8d12f82ad --- /dev/null +++ b/modules/gcloud/src/main/java/org/testcontainers/containers/FirestoreEmulatorContainer.java @@ -0,0 +1,66 @@ +package org.testcontainers.containers; + +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * A Firestore container that relies in google cloud sdk. + *

+ * Supported images: {@code gcr.io/google.com/cloudsdktool/google-cloud-cli}, {@code gcr.io/google.com/cloudsdktool/cloud-sdk} + *

+ * Default port is 8080. + * + * @deprecated use {@link org.testcontainers.gcloud.FirestoreEmulatorContainer} instead. + */ +@Deprecated +public class FirestoreEmulatorContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/google-cloud-cli" + ); + + private static final DockerImageName CLOUD_SDK_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/cloud-sdk" + ); + + private static final String CMD = "gcloud beta emulators firestore start --host-port 0.0.0.0:8080"; + + private static final int PORT = 8080; + + private String flags; + + public FirestoreEmulatorContainer(String image) { + this(DockerImageName.parse(image)); + } + + public FirestoreEmulatorContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, CLOUD_SDK_IMAGE_NAME); + + withExposedPorts(PORT); + setWaitStrategy(Wait.forLogMessage(".*running.*$", 1)); + } + + @Override + protected void configure() { + String command = CMD; + if (this.flags != null && !this.flags.isEmpty()) { + command += " " + this.flags; + } + withCommand("/bin/sh", "-c", command); + } + + public FirestoreEmulatorContainer withFlags(String flags) { + this.flags = flags; + return this; + } + + /** + * @return a host:port pair corresponding to the address on which the emulator is + * reachable from the test host machine. Directly usable as a parameter to the + * com.google.cloud.ServiceOptions.Builder#setHost(java.lang.String) method. + */ + public String getEmulatorEndpoint() { + return getHost() + ":" + getMappedPort(8080); + } +} diff --git a/modules/gcloud/src/main/java/org/testcontainers/containers/PubSubEmulatorContainer.java b/modules/gcloud/src/main/java/org/testcontainers/containers/PubSubEmulatorContainer.java new file mode 100644 index 00000000000..9f061873962 --- /dev/null +++ b/modules/gcloud/src/main/java/org/testcontainers/containers/PubSubEmulatorContainer.java @@ -0,0 +1,51 @@ +package org.testcontainers.containers; + +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * A PubSub container that relies in google cloud sdk. + *

+ * Supported images: {@code gcr.io/google.com/cloudsdktool/google-cloud-cli}, {@code gcr.io/google.com/cloudsdktool/cloud-sdk} + *

+ * Default port is 8085. + * + * @deprecated use {@link org.testcontainers.gcloud.PubSubEmulatorContainer} instead. + */ +@Deprecated +public class PubSubEmulatorContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/google-cloud-cli" + ); + + private static final DockerImageName CLOUD_SDK_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/cloud-sdk" + ); + + private static final String CMD = "gcloud beta emulators pubsub start --host-port 0.0.0.0:8085"; + + private static final int PORT = 8085; + + public PubSubEmulatorContainer(String image) { + this(DockerImageName.parse(image)); + } + + public PubSubEmulatorContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, CLOUD_SDK_IMAGE_NAME); + + withExposedPorts(8085); + setWaitStrategy(Wait.forLogMessage(".*started.*$", 1)); + withCommand("/bin/sh", "-c", CMD); + } + + /** + * @return a host:port pair corresponding to the address on which the emulator is + * reachable from the test host machine. Directly usable as a parameter to the + * io.grpc.ManagedChannelBuilder#forTarget(java.lang.String) method. + */ + public String getEmulatorEndpoint() { + return getHost() + ":" + getMappedPort(PORT); + } +} diff --git a/modules/gcloud/src/main/java/org/testcontainers/containers/SpannerEmulatorContainer.java b/modules/gcloud/src/main/java/org/testcontainers/containers/SpannerEmulatorContainer.java new file mode 100644 index 00000000000..76a95d4c4ac --- /dev/null +++ b/modules/gcloud/src/main/java/org/testcontainers/containers/SpannerEmulatorContainer.java @@ -0,0 +1,52 @@ +package org.testcontainers.containers; + +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * A Spanner container. Default ports: 9010 for GRPC and 9020 for HTTP. + *

+ * Supported image: {@code gcr.io/cloud-spanner-emulator/emulator} + * + * @deprecated use {@link org.testcontainers.gcloud.SpannerEmulatorContainer} instead. + */ +@Deprecated +public class SpannerEmulatorContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "gcr.io/cloud-spanner-emulator/emulator" + ); + + private static final int GRPC_PORT = 9010; + + private static final int HTTP_PORT = 9020; + + public SpannerEmulatorContainer(String image) { + this(DockerImageName.parse(image)); + } + + public SpannerEmulatorContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + withExposedPorts(GRPC_PORT, HTTP_PORT); + setWaitStrategy(Wait.forLogMessage(".*Cloud Spanner emulator running\\..*", 1)); + } + + /** + * @return a host:port pair corresponding to the address on which the emulator's + * gRPC endpoint is reachable from the test host machine. Directly usable as a parameter to the + * com.google.cloud.spanner.SpannerOptions.Builder#setEmulatorHost(java.lang.String) method. + */ + public String getEmulatorGrpcEndpoint() { + return getHost() + ":" + getMappedPort(GRPC_PORT); + } + + /** + * @return a host:port pair corresponding to the address on which the emulator's + * HTTP REST endpoint is reachable from the test host machine. + */ + public String getEmulatorHttpEndpoint() { + return getHost() + ":" + getMappedPort(HTTP_PORT); + } +} diff --git a/modules/gcloud/src/main/java/org/testcontainers/gcloud/BigQueryEmulatorContainer.java b/modules/gcloud/src/main/java/org/testcontainers/gcloud/BigQueryEmulatorContainer.java new file mode 100644 index 00000000000..b5d00bf374a --- /dev/null +++ b/modules/gcloud/src/main/java/org/testcontainers/gcloud/BigQueryEmulatorContainer.java @@ -0,0 +1,44 @@ +package org.testcontainers.gcloud; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for BigQuery. + *

+ * Supported image: {@code ghcr.io/goccy/bigquery-emulator} + *

+ */ +public class BigQueryEmulatorContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("ghcr.io/goccy/bigquery-emulator"); + + private static final int HTTP_PORT = 9050; + + private static final int GRPC_PORT = 9060; + + private static final String PROJECT_ID = "test-project"; + + public BigQueryEmulatorContainer(String image) { + this(DockerImageName.parse(image)); + } + + public BigQueryEmulatorContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + addExposedPorts(HTTP_PORT, GRPC_PORT); + withCommand("--project", PROJECT_ID); + } + + public String getEmulatorHttpEndpoint() { + return String.format("http://%s:%d", getHost(), getMappedPort(HTTP_PORT)); + } + + public Integer getEmulatorGrpcPort() { + return getMappedPort(GRPC_PORT); + } + + public String getProjectId() { + return PROJECT_ID; + } +} diff --git a/modules/gcloud/src/main/java/org/testcontainers/gcloud/BigtableEmulatorContainer.java b/modules/gcloud/src/main/java/org/testcontainers/gcloud/BigtableEmulatorContainer.java new file mode 100644 index 00000000000..b1e62ae0aa1 --- /dev/null +++ b/modules/gcloud/src/main/java/org/testcontainers/gcloud/BigtableEmulatorContainer.java @@ -0,0 +1,53 @@ +package org.testcontainers.gcloud; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * A Bigtable container that relies in google cloud sdk. + *

+ * Supported images: {@code gcr.io/google.com/cloudsdktool/google-cloud-cli}, {@code gcr.io/google.com/cloudsdktool/cloud-sdk} + *

+ * Default port is 9000. + */ +public class BigtableEmulatorContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/google-cloud-cli" + ); + + private static final DockerImageName CLOUD_SDK_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/cloud-sdk" + ); + + private static final String CMD = "gcloud beta emulators bigtable start --host-port 0.0.0.0:9000"; + + private static final int PORT = 9000; + + public BigtableEmulatorContainer(String image) { + this(DockerImageName.parse(image)); + } + + public BigtableEmulatorContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, CLOUD_SDK_IMAGE_NAME); + + withExposedPorts(PORT); + setWaitStrategy(Wait.forLogMessage(".*running.*$", 1)); + withCommand("/bin/sh", "-c", CMD); + } + + /** + * @return a host:port pair corresponding to the address on which the emulator is + * reachable from the test host machine. Directly usable as a parameter to the + * com.google.cloud.ServiceOptions.Builder#setHost(java.lang.String) method. + */ + public String getEmulatorEndpoint() { + return getHost() + ":" + getEmulatorPort(); + } + + public int getEmulatorPort() { + return getMappedPort(PORT); + } +} diff --git a/modules/gcloud/src/main/java/org/testcontainers/gcloud/DatastoreEmulatorContainer.java b/modules/gcloud/src/main/java/org/testcontainers/gcloud/DatastoreEmulatorContainer.java new file mode 100644 index 00000000000..360aedce686 --- /dev/null +++ b/modules/gcloud/src/main/java/org/testcontainers/gcloud/DatastoreEmulatorContainer.java @@ -0,0 +1,73 @@ +package org.testcontainers.gcloud; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * A Datastore container that relies in google cloud sdk. + *

+ * Supported images: {@code gcr.io/google.com/cloudsdktool/google-cloud-cli}, {@code gcr.io/google.com/cloudsdktool/cloud-sdk} + *

+ * Default port is 8081. + */ +public class DatastoreEmulatorContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/google-cloud-cli" + ); + + private static final DockerImageName CLOUD_SDK_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/cloud-sdk" + ); + + private static final String PROJECT_ID = "test-project"; + + private static final String CMD = String.format( + "gcloud beta emulators datastore start --project %s --host-port 0.0.0.0:8081", + PROJECT_ID + ); + + private static final int HTTP_PORT = 8081; + + private String flags; + + public DatastoreEmulatorContainer(final String image) { + this(DockerImageName.parse(image)); + } + + public DatastoreEmulatorContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, CLOUD_SDK_IMAGE_NAME); + + withExposedPorts(HTTP_PORT); + setWaitStrategy(Wait.forHttp("/").forStatusCode(200)); + } + + @Override + protected void configure() { + String command = CMD; + if (this.flags != null && !this.flags.isEmpty()) { + command += " " + this.flags; + } + withCommand("/bin/sh", "-c", command); + } + + public DatastoreEmulatorContainer withFlags(String flags) { + this.flags = flags; + return this; + } + + /** + * @return a host:port pair corresponding to the address on which the emulator is + * reachable from the test host machine. Directly usable as a parameter to the + * com.google.cloud.ServiceOptions.Builder#setHost(java.lang.String) method. + */ + public String getEmulatorEndpoint() { + return getHost() + ":" + getMappedPort(HTTP_PORT); + } + + public String getProjectId() { + return PROJECT_ID; + } +} diff --git a/modules/gcloud/src/main/java/org/testcontainers/gcloud/FirestoreEmulatorContainer.java b/modules/gcloud/src/main/java/org/testcontainers/gcloud/FirestoreEmulatorContainer.java new file mode 100644 index 00000000000..140f7166307 --- /dev/null +++ b/modules/gcloud/src/main/java/org/testcontainers/gcloud/FirestoreEmulatorContainer.java @@ -0,0 +1,64 @@ +package org.testcontainers.gcloud; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * A Firestore container that relies in google cloud sdk. + *

+ * Supported images: {@code gcr.io/google.com/cloudsdktool/google-cloud-cli}, {@code gcr.io/google.com/cloudsdktool/cloud-sdk} + *

+ * Default port is 8080. + */ +public class FirestoreEmulatorContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/google-cloud-cli" + ); + + private static final DockerImageName CLOUD_SDK_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/cloud-sdk" + ); + + private static final String CMD = "gcloud beta emulators firestore start --host-port 0.0.0.0:8080"; + + private static final int PORT = 8080; + + private String flags; + + public FirestoreEmulatorContainer(String image) { + this(DockerImageName.parse(image)); + } + + public FirestoreEmulatorContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, CLOUD_SDK_IMAGE_NAME); + + withExposedPorts(PORT); + setWaitStrategy(Wait.forLogMessage(".*running.*$", 1)); + } + + @Override + protected void configure() { + String command = CMD; + if (this.flags != null && !this.flags.isEmpty()) { + command += " " + this.flags; + } + withCommand("/bin/sh", "-c", command); + } + + public FirestoreEmulatorContainer withFlags(String flags) { + this.flags = flags; + return this; + } + + /** + * @return a host:port pair corresponding to the address on which the emulator is + * reachable from the test host machine. Directly usable as a parameter to the + * com.google.cloud.ServiceOptions.Builder#setHost(java.lang.String) method. + */ + public String getEmulatorEndpoint() { + return getHost() + ":" + getMappedPort(8080); + } +} diff --git a/modules/gcloud/src/main/java/org/testcontainers/gcloud/PubSubEmulatorContainer.java b/modules/gcloud/src/main/java/org/testcontainers/gcloud/PubSubEmulatorContainer.java new file mode 100644 index 00000000000..417ee8dc524 --- /dev/null +++ b/modules/gcloud/src/main/java/org/testcontainers/gcloud/PubSubEmulatorContainer.java @@ -0,0 +1,49 @@ +package org.testcontainers.gcloud; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * A PubSub container that relies in google cloud sdk. + *

+ * Supported images: {@code gcr.io/google.com/cloudsdktool/google-cloud-cli}, {@code gcr.io/google.com/cloudsdktool/cloud-sdk} + *

+ * Default port is 8085. + */ +public class PubSubEmulatorContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/google-cloud-cli" + ); + + private static final DockerImageName CLOUD_SDK_IMAGE_NAME = DockerImageName.parse( + "gcr.io/google.com/cloudsdktool/cloud-sdk" + ); + + private static final String CMD = "gcloud beta emulators pubsub start --host-port 0.0.0.0:8085"; + + private static final int PORT = 8085; + + public PubSubEmulatorContainer(String image) { + this(DockerImageName.parse(image)); + } + + public PubSubEmulatorContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, CLOUD_SDK_IMAGE_NAME); + + withExposedPorts(8085); + setWaitStrategy(Wait.forLogMessage(".*started.*$", 1)); + withCommand("/bin/sh", "-c", CMD); + } + + /** + * @return a host:port pair corresponding to the address on which the emulator is + * reachable from the test host machine. Directly usable as a parameter to the + * io.grpc.ManagedChannelBuilder#forTarget(java.lang.String) method. + */ + public String getEmulatorEndpoint() { + return getHost() + ":" + getMappedPort(PORT); + } +} diff --git a/modules/gcloud/src/main/java/org/testcontainers/gcloud/SpannerEmulatorContainer.java b/modules/gcloud/src/main/java/org/testcontainers/gcloud/SpannerEmulatorContainer.java new file mode 100644 index 00000000000..aa99f6be5f4 --- /dev/null +++ b/modules/gcloud/src/main/java/org/testcontainers/gcloud/SpannerEmulatorContainer.java @@ -0,0 +1,50 @@ +package org.testcontainers.gcloud; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * A Spanner container. Default ports: 9010 for GRPC and 9020 for HTTP. + *

+ * Supported image: {@code gcr.io/cloud-spanner-emulator/emulator} + */ +public class SpannerEmulatorContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "gcr.io/cloud-spanner-emulator/emulator" + ); + + private static final int GRPC_PORT = 9010; + + private static final int HTTP_PORT = 9020; + + public SpannerEmulatorContainer(String image) { + this(DockerImageName.parse(image)); + } + + public SpannerEmulatorContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + withExposedPorts(GRPC_PORT, HTTP_PORT); + setWaitStrategy(Wait.forLogMessage(".*Cloud Spanner emulator running\\..*", 1)); + } + + /** + * @return a host:port pair corresponding to the address on which the emulator's + * gRPC endpoint is reachable from the test host machine. Directly usable as a parameter to the + * com.google.cloud.spanner.SpannerOptions.Builder#setEmulatorHost(java.lang.String) method. + */ + public String getEmulatorGrpcEndpoint() { + return getHost() + ":" + getMappedPort(GRPC_PORT); + } + + /** + * @return a host:port pair corresponding to the address on which the emulator's + * HTTP REST endpoint is reachable from the test host machine. + */ + public String getEmulatorHttpEndpoint() { + return getHost() + ":" + getMappedPort(HTTP_PORT); + } +} diff --git a/modules/gcloud/src/test/java/org/testcontainers/gcloud/BigQueryEmulatorContainerTest.java b/modules/gcloud/src/test/java/org/testcontainers/gcloud/BigQueryEmulatorContainerTest.java new file mode 100644 index 00000000000..7d657830fa4 --- /dev/null +++ b/modules/gcloud/src/test/java/org/testcontainers/gcloud/BigQueryEmulatorContainerTest.java @@ -0,0 +1,204 @@ +package org.testcontainers.gcloud; + +import com.google.api.core.ApiFuture; +import com.google.api.gax.core.NoCredentialsProvider; +import com.google.api.gax.grpc.GrpcTransportChannel; +import com.google.api.gax.rpc.FixedTransportChannelProvider; +import com.google.cloud.NoCredentials; +import com.google.cloud.bigquery.BigQuery; +import com.google.cloud.bigquery.BigQueryOptions; +import com.google.cloud.bigquery.DatasetId; +import com.google.cloud.bigquery.DatasetInfo; +import com.google.cloud.bigquery.Field; +import com.google.cloud.bigquery.QueryJobConfiguration; +import com.google.cloud.bigquery.Schema; +import com.google.cloud.bigquery.StandardSQLTypeName; +import com.google.cloud.bigquery.StandardTableDefinition; +import com.google.cloud.bigquery.TableDefinition; +import com.google.cloud.bigquery.TableId; +import com.google.cloud.bigquery.TableInfo; +import com.google.cloud.bigquery.TableResult; +import com.google.cloud.bigquery.storage.v1.AppendRowsResponse; +import com.google.cloud.bigquery.storage.v1.BatchCommitWriteStreamsRequest; +import com.google.cloud.bigquery.storage.v1.BatchCommitWriteStreamsResponse; +import com.google.cloud.bigquery.storage.v1.BigQueryWriteClient; +import com.google.cloud.bigquery.storage.v1.BigQueryWriteSettings; +import com.google.cloud.bigquery.storage.v1.CreateWriteStreamRequest; +import com.google.cloud.bigquery.storage.v1.FinalizeWriteStreamRequest; +import com.google.cloud.bigquery.storage.v1.FinalizeWriteStreamResponse; +import com.google.cloud.bigquery.storage.v1.JsonStreamWriter; +import com.google.cloud.bigquery.storage.v1.TableName; +import com.google.cloud.bigquery.storage.v1.WriteStream; +import io.grpc.ManagedChannelBuilder; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import org.threeten.bp.Duration; + +import java.math.BigDecimal; +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +class BigQueryEmulatorContainerTest { + + @Test + void testHttpEndpoint() throws Exception { + try ( + // emulatorContainer { + BigQueryEmulatorContainer container = new BigQueryEmulatorContainer("ghcr.io/goccy/bigquery-emulator:0.4.3") + // } + ) { + container.start(); + + // bigQueryClient { + String url = container.getEmulatorHttpEndpoint(); + BigQueryOptions options = BigQueryOptions + .newBuilder() + .setProjectId(container.getProjectId()) + .setHost(url) + .setLocation(url) + .setCredentials(NoCredentials.getInstance()) + .build(); + BigQuery bigQuery = options.getService(); + // } + + String fn = + "CREATE FUNCTION testr(arr ARRAY>) AS ((SELECT SUM(IF(elem.name = \"foo\",elem.val,null)) FROM UNNEST(arr) AS elem))"; + + bigQuery.query(QueryJobConfiguration.newBuilder(fn).build()); + + String sql = + "SELECT testr([STRUCT(\"foo\", 10), STRUCT(\"bar\", 40), STRUCT(\"foo\", 20)])"; + TableResult result = bigQuery.query(QueryJobConfiguration.newBuilder(sql).build()); + List values = result + .streamValues() + .map(fieldValues -> fieldValues.get(0).getNumericValue()) + .collect(Collectors.toList()); + assertThat(values).containsOnly(BigDecimal.valueOf(30)); + } + } + + @Test + void testGrcpEndpoint() throws Exception { + try ( + BigQueryEmulatorContainer container = new BigQueryEmulatorContainer("ghcr.io/goccy/bigquery-emulator:0.6.5") + ) { + container.start(); + + BigQuery bigQuery = getBigQuery(container); + String tableName = "test-table"; + String datasetName = "test-dataset"; + + bigQuery.create(DatasetInfo.of(DatasetId.of(container.getProjectId(), datasetName))); + + Schema schema = Schema.of(Field.of("name", StandardSQLTypeName.STRING)); + + TableId tableId = TableId.of(datasetName, tableName); + TableDefinition tableDefinition = StandardTableDefinition.of(schema); + TableInfo tableInfo = TableInfo.newBuilder(tableId, tableDefinition).build(); + + bigQuery.create(tableInfo); + + BigQueryWriteSettings.Builder bigQueryWriteSettingsBuilder = BigQueryWriteSettings.newBuilder(); + + bigQueryWriteSettingsBuilder + .createWriteStreamSettings() + .setRetrySettings( + bigQueryWriteSettingsBuilder + .createWriteStreamSettings() + .getRetrySettings() + .toBuilder() + .setTotalTimeout(Duration.ofSeconds(60)) + .build() + ); + + BigQueryWriteClient bigQueryWriteClient = BigQueryWriteClient.create( + bigQueryWriteSettingsBuilder + .setTransportChannelProvider( + FixedTransportChannelProvider.create( + GrpcTransportChannel.create( + ManagedChannelBuilder + .forAddress(container.getHost(), container.getEmulatorGrpcPort()) + .usePlaintext() + .build() + ) + ) + ) + .setCredentialsProvider(NoCredentialsProvider.create()) + .build() + ); + + TableName parentTable = TableName.of(container.getProjectId(), datasetName, tableName); + CreateWriteStreamRequest createWriteStreamRequest = CreateWriteStreamRequest + .newBuilder() + .setParent(parentTable.toString()) + .setWriteStream(WriteStream.newBuilder().setType(WriteStream.Type.PENDING)) + .build(); + + WriteStream writeStream = bigQueryWriteClient.createWriteStream(createWriteStreamRequest); + + JsonStreamWriter writer = JsonStreamWriter + .newBuilder(writeStream.getName(), writeStream.getTableSchema(), bigQueryWriteClient) + .build(); + + JSONArray jsonArray = new JSONArray(); + JSONObject record1 = new JSONObject(); + record1.put("name", "Alice"); + jsonArray.put(record1); + + JSONObject record2 = new JSONObject(); + record2.put("name", "Bob"); + jsonArray.put(record2); + + ApiFuture future = writer.append(jsonArray); + AppendRowsResponse response = future.get(); + + FinalizeWriteStreamRequest finalizeRequest = FinalizeWriteStreamRequest + .newBuilder() + .setName(writeStream.getName()) + .build(); + FinalizeWriteStreamResponse finalizeResponse = bigQueryWriteClient.finalizeWriteStream(finalizeRequest); + + BatchCommitWriteStreamsRequest commitRequest = BatchCommitWriteStreamsRequest + .newBuilder() + .setParent(parentTable.toString()) + .addWriteStreams(writeStream.getName()) + .build(); + BatchCommitWriteStreamsResponse commitResponse = bigQueryWriteClient.batchCommitWriteStreams(commitRequest); + + writer.close(); + + String sql = String.format( + "SELECT name FROM `%s.%s.%s` ORDER BY name", + container.getProjectId(), + datasetName, + tableName + ); + TableResult result = bigQuery.query(QueryJobConfiguration.newBuilder(sql).build()); + + List names = result + .streamValues() + .map(row -> row.get("name").getStringValue()) + .collect(Collectors.toList()); + + assertThat(names).containsExactly("Alice", "Bob"); + + bigQueryWriteClient.shutdown(); + bigQueryWriteClient.close(); + } + } + + private BigQuery getBigQuery(BigQueryEmulatorContainer container) { + String url = container.getEmulatorHttpEndpoint(); + return BigQueryOptions + .newBuilder() + .setProjectId(container.getProjectId()) + .setHost(url) + .setLocation(url) + .setCredentials(NoCredentials.getInstance()) + .build() + .getService(); + } +} diff --git a/modules/gcloud/src/test/java/org/testcontainers/gcloud/BigtableEmulatorContainerTest.java b/modules/gcloud/src/test/java/org/testcontainers/gcloud/BigtableEmulatorContainerTest.java new file mode 100644 index 00000000000..64f19a42df5 --- /dev/null +++ b/modules/gcloud/src/test/java/org/testcontainers/gcloud/BigtableEmulatorContainerTest.java @@ -0,0 +1,102 @@ +package org.testcontainers.gcloud; + +import com.google.api.gax.core.CredentialsProvider; +import com.google.api.gax.core.NoCredentialsProvider; +import com.google.api.gax.grpc.GrpcTransportChannel; +import com.google.api.gax.rpc.FixedTransportChannelProvider; +import com.google.api.gax.rpc.TransportChannelProvider; +import com.google.cloud.bigtable.admin.v2.BigtableTableAdminClient; +import com.google.cloud.bigtable.admin.v2.models.CreateTableRequest; +import com.google.cloud.bigtable.admin.v2.models.Table; +import com.google.cloud.bigtable.admin.v2.stub.BigtableTableAdminStubSettings; +import com.google.cloud.bigtable.admin.v2.stub.EnhancedBigtableTableAdminStub; +import com.google.cloud.bigtable.data.v2.BigtableDataClient; +import com.google.cloud.bigtable.data.v2.BigtableDataSettings; +import com.google.cloud.bigtable.data.v2.internal.TableAdminRequestContext; +import com.google.cloud.bigtable.data.v2.models.Row; +import com.google.cloud.bigtable.data.v2.models.RowCell; +import com.google.cloud.bigtable.data.v2.models.RowMutation; +import com.google.cloud.bigtable.data.v2.models.TableId; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class BigtableEmulatorContainerTest { + + private static final String PROJECT_ID = "test-project"; + + private static final String INSTANCE_ID = "test-instance"; + + @Test + // testWithEmulatorContainer { + void testSimple() throws IOException { + try ( + // emulatorContainer { + BigtableEmulatorContainer emulator = new BigtableEmulatorContainer( + DockerImageName.parse("gcr.io/google.com/cloudsdktool/google-cloud-cli:441.0.0-emulators") + ); + // } + ) { + emulator.start(); + ManagedChannel channel = ManagedChannelBuilder + .forTarget(emulator.getEmulatorEndpoint()) + .usePlaintext() + .build(); + + TransportChannelProvider channelProvider = FixedTransportChannelProvider.create( + GrpcTransportChannel.create(channel) + ); + NoCredentialsProvider credentialsProvider = NoCredentialsProvider.create(); + createTable(channelProvider, credentialsProvider, "test-table"); + try ( + BigtableDataClient client = BigtableDataClient.create( + BigtableDataSettings + .newBuilderForEmulator(emulator.getHost(), emulator.getEmulatorPort()) + .setProjectId(PROJECT_ID) + .setInstanceId(INSTANCE_ID) + .build() + ) + ) { + client.mutateRow(RowMutation.create(TableId.of("test-table"), "1").setCell("name", "firstName", "Ray")); + + Row row = client.readRow(TableId.of("test-table"), "1"); + List cells = row.getCells("name", "firstName"); + + assertThat(cells).isNotNull().hasSize(1); + assertThat(cells.get(0).getValue().toStringUtf8()).isEqualTo("Ray"); + } finally { + channel.shutdown(); + } + } + } + + // } + + // createTable { + private void createTable( + TransportChannelProvider channelProvider, + CredentialsProvider credentialsProvider, + String tableName + ) throws IOException { + TableAdminRequestContext requestContext = TableAdminRequestContext.create(PROJECT_ID, INSTANCE_ID); + EnhancedBigtableTableAdminStub stub = EnhancedBigtableTableAdminStub.createEnhanced( + BigtableTableAdminStubSettings + .newBuilder() + .setTransportChannelProvider(channelProvider) + .setCredentialsProvider(credentialsProvider) + .build(), + requestContext + ); + + try (BigtableTableAdminClient client = BigtableTableAdminClient.create(PROJECT_ID, INSTANCE_ID, stub)) { + Table table = client.createTable(CreateTableRequest.of(tableName).addFamily("name")); + } + } + // } +} diff --git a/modules/gcloud/src/test/java/org/testcontainers/gcloud/DatastoreEmulatorContainerTest.java b/modules/gcloud/src/test/java/org/testcontainers/gcloud/DatastoreEmulatorContainerTest.java new file mode 100644 index 00000000000..dceec1af215 --- /dev/null +++ b/modules/gcloud/src/test/java/org/testcontainers/gcloud/DatastoreEmulatorContainerTest.java @@ -0,0 +1,77 @@ +package org.testcontainers.gcloud; + +import com.google.cloud.NoCredentials; +import com.google.cloud.ServiceOptions; +import com.google.cloud.datastore.Datastore; +import com.google.cloud.datastore.DatastoreOptions; +import com.google.cloud.datastore.Entity; +import com.google.cloud.datastore.Key; +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +public class DatastoreEmulatorContainerTest { + + // startingDatastoreEmulatorContainer { + @Test + public void testSimple() { + try ( + // creatingDatastoreEmulatorContainer { + DatastoreEmulatorContainer emulator = new DatastoreEmulatorContainer( + DockerImageName.parse("gcr.io/google.com/cloudsdktool/google-cloud-cli:441.0.0-emulators") + ); + // } + ) { + emulator.start(); + DatastoreOptions options = DatastoreOptions + .newBuilder() + .setHost(emulator.getEmulatorEndpoint()) + .setCredentials(NoCredentials.getInstance()) + .setRetrySettings(ServiceOptions.getNoRetrySettings()) + .setProjectId(emulator.getProjectId()) + .build(); + Datastore datastore = options.getService(); + + Key key = datastore.newKeyFactory().setKind("Task").newKey("sample"); + Entity entity = Entity.newBuilder(key).set("description", "my description").build(); + datastore.put(entity); + + assertThat(datastore.get(key).getString("description")).isEqualTo("my description"); + } + } + + // } + + @Test + void testWithFlags() throws IOException, InterruptedException { + try ( + DatastoreEmulatorContainer emulator = new DatastoreEmulatorContainer( + "gcr.io/google.com/cloudsdktool/google-cloud-cli:441.0.0-emulators" + ) + .withFlags("--consistency 1.0") + ) { + emulator.start(); + + assertThat(emulator.getContainerInfo().getConfig().getCmd()).anyMatch(e -> e.contains("--consistency 1.0")); + assertThat(emulator.execInContainer("ls", "/root/.config/").getStdout()).contains("gcloud"); + } + } + + @Test + void testWithMultipleFlags() throws IOException, InterruptedException { + try ( + DatastoreEmulatorContainer emulator = new DatastoreEmulatorContainer( + "gcr.io/google.com/cloudsdktool/google-cloud-cli:441.0.0-emulators" + ) + .withFlags("--consistency 1.0 --data-dir /root/.config/test-gcloud") + ) { + emulator.start(); + + assertThat(emulator.getContainerInfo().getConfig().getCmd()).anyMatch(e -> e.contains("--consistency 1.0")); + assertThat(emulator.execInContainer("ls", "/root/.config/").getStdout()).contains("test-gcloud"); + } + } +} diff --git a/modules/gcloud/src/test/java/org/testcontainers/gcloud/FirestoreEmulatorContainerTest.java b/modules/gcloud/src/test/java/org/testcontainers/gcloud/FirestoreEmulatorContainerTest.java new file mode 100644 index 00000000000..2c3233388aa --- /dev/null +++ b/modules/gcloud/src/test/java/org/testcontainers/gcloud/FirestoreEmulatorContainerTest.java @@ -0,0 +1,73 @@ +package org.testcontainers.gcloud; + +import com.google.api.core.ApiFuture; +import com.google.cloud.NoCredentials; +import com.google.cloud.firestore.CollectionReference; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.Firestore; +import com.google.cloud.firestore.FirestoreOptions; +import com.google.cloud.firestore.QuerySnapshot; +import com.google.cloud.firestore.WriteResult; +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.DockerImageName; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import static org.assertj.core.api.Assertions.assertThat; + +class FirestoreEmulatorContainerTest { + + // testWithEmulatorContainer { + @Test + void testSimple() throws ExecutionException, InterruptedException { + try ( + // emulatorContainer { + FirestoreEmulatorContainer emulator = new FirestoreEmulatorContainer( + DockerImageName.parse("gcr.io/google.com/cloudsdktool/google-cloud-cli:441.0.0-emulators") + ); + // } + ) { + emulator.start(); + FirestoreOptions options = FirestoreOptions + .getDefaultInstance() + .toBuilder() + .setHost(emulator.getEmulatorEndpoint()) + .setCredentials(NoCredentials.getInstance()) + .setProjectId("test-project") + .build(); + Firestore firestore = options.getService(); + + CollectionReference users = firestore.collection("users"); + DocumentReference docRef = users.document("alovelace"); + Map data = new HashMap<>(); + data.put("first", "Ada"); + data.put("last", "Lovelace"); + ApiFuture result = docRef.set(data); + result.get(); + + ApiFuture query = users.get(); + QuerySnapshot querySnapshot = query.get(); + + assertThat(querySnapshot.getDocuments().get(0).getData()).containsEntry("first", "Ada"); + } + } + + // } + + @Test + void testWithFlags() { + try ( + FirestoreEmulatorContainer emulator = new FirestoreEmulatorContainer( + "gcr.io/google.com/cloudsdktool/google-cloud-cli:465.0.0-emulators" + ) + .withFlags("--database-mode datastore-mode") + ) { + emulator.start(); + + assertThat(emulator.getContainerInfo().getConfig().getCmd()) + .anyMatch(e -> e.contains("--database-mode datastore-mode")); + } + } +} diff --git a/modules/gcloud/src/test/java/org/testcontainers/gcloud/PubSubEmulatorContainerTest.java b/modules/gcloud/src/test/java/org/testcontainers/gcloud/PubSubEmulatorContainerTest.java new file mode 100644 index 00000000000..eb3ed0260ee --- /dev/null +++ b/modules/gcloud/src/test/java/org/testcontainers/gcloud/PubSubEmulatorContainerTest.java @@ -0,0 +1,139 @@ +package org.testcontainers.gcloud; + +import com.google.api.gax.core.NoCredentialsProvider; +import com.google.api.gax.grpc.GrpcTransportChannel; +import com.google.api.gax.rpc.FixedTransportChannelProvider; +import com.google.api.gax.rpc.TransportChannelProvider; +import com.google.cloud.pubsub.v1.Publisher; +import com.google.cloud.pubsub.v1.SubscriptionAdminClient; +import com.google.cloud.pubsub.v1.SubscriptionAdminSettings; +import com.google.cloud.pubsub.v1.TopicAdminClient; +import com.google.cloud.pubsub.v1.TopicAdminSettings; +import com.google.cloud.pubsub.v1.stub.GrpcSubscriberStub; +import com.google.cloud.pubsub.v1.stub.SubscriberStub; +import com.google.cloud.pubsub.v1.stub.SubscriberStubSettings; +import com.google.protobuf.ByteString; +import com.google.pubsub.v1.ProjectSubscriptionName; +import com.google.pubsub.v1.PubsubMessage; +import com.google.pubsub.v1.PullRequest; +import com.google.pubsub.v1.PullResponse; +import com.google.pubsub.v1.PushConfig; +import com.google.pubsub.v1.SubscriptionName; +import com.google.pubsub.v1.TopicName; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +class PubSubEmulatorContainerTest { + + private static final String PROJECT_ID = "my-project-id"; + + @Test + // testWithEmulatorContainer { + void testSimple() throws IOException { + try ( + // emulatorContainer { + PubSubEmulatorContainer emulator = new PubSubEmulatorContainer( + DockerImageName.parse("gcr.io/google.com/cloudsdktool/google-cloud-cli:441.0.0-emulators") + ); + // } + ) { + emulator.start(); + String hostport = emulator.getEmulatorEndpoint(); + ManagedChannel channel = ManagedChannelBuilder.forTarget(hostport).usePlaintext().build(); + try { + TransportChannelProvider channelProvider = FixedTransportChannelProvider.create( + GrpcTransportChannel.create(channel) + ); + NoCredentialsProvider credentialsProvider = NoCredentialsProvider.create(); + + String topicId = "my-topic-id"; + createTopic(topicId, channelProvider, credentialsProvider); + + String subscriptionId = "my-subscription-id"; + createSubscription(subscriptionId, topicId, channelProvider, credentialsProvider); + + Publisher publisher = Publisher + .newBuilder(TopicName.of(PROJECT_ID, topicId)) + .setChannelProvider(channelProvider) + .setCredentialsProvider(credentialsProvider) + .build(); + PubsubMessage message = PubsubMessage + .newBuilder() + .setData(ByteString.copyFromUtf8("test message")) + .build(); + publisher.publish(message); + + SubscriberStubSettings subscriberStubSettings = SubscriberStubSettings + .newBuilder() + .setTransportChannelProvider(channelProvider) + .setCredentialsProvider(credentialsProvider) + .build(); + try (SubscriberStub subscriber = GrpcSubscriberStub.create(subscriberStubSettings)) { + PullRequest pullRequest = PullRequest + .newBuilder() + .setMaxMessages(1) + .setSubscription(ProjectSubscriptionName.format(PROJECT_ID, subscriptionId)) + .build(); + PullResponse pullResponse = subscriber.pullCallable().call(pullRequest); + + assertThat(pullResponse.getReceivedMessagesList()).hasSize(1); + assertThat(pullResponse.getReceivedMessages(0).getMessage().getData().toStringUtf8()) + .isEqualTo("test message"); + } + } finally { + channel.shutdown(); + } + } + } + + // } + + // createTopic { + private void createTopic( + String topicId, + TransportChannelProvider channelProvider, + NoCredentialsProvider credentialsProvider + ) throws IOException { + TopicAdminSettings topicAdminSettings = TopicAdminSettings + .newBuilder() + .setTransportChannelProvider(channelProvider) + .setCredentialsProvider(credentialsProvider) + .build(); + try (TopicAdminClient topicAdminClient = TopicAdminClient.create(topicAdminSettings)) { + TopicName topicName = TopicName.of(PROJECT_ID, topicId); + topicAdminClient.createTopic(topicName); + } + } + + // } + + // createSubscription { + private void createSubscription( + String subscriptionId, + String topicId, + TransportChannelProvider channelProvider, + NoCredentialsProvider credentialsProvider + ) throws IOException { + SubscriptionAdminSettings subscriptionAdminSettings = SubscriptionAdminSettings + .newBuilder() + .setTransportChannelProvider(channelProvider) + .setCredentialsProvider(credentialsProvider) + .build(); + SubscriptionAdminClient subscriptionAdminClient = SubscriptionAdminClient.create(subscriptionAdminSettings); + SubscriptionName subscriptionName = SubscriptionName.of(PROJECT_ID, subscriptionId); + subscriptionAdminClient.createSubscription( + subscriptionName, + TopicName.of(PROJECT_ID, topicId), + PushConfig.getDefaultInstance(), + 10 + ); + } + // } + +} diff --git a/modules/gcloud/src/test/java/org/testcontainers/gcloud/SpannerEmulatorContainerTest.java b/modules/gcloud/src/test/java/org/testcontainers/gcloud/SpannerEmulatorContainerTest.java new file mode 100644 index 00000000000..2d6ac963882 --- /dev/null +++ b/modules/gcloud/src/test/java/org/testcontainers/gcloud/SpannerEmulatorContainerTest.java @@ -0,0 +1,113 @@ +package org.testcontainers.gcloud; + +import com.google.cloud.NoCredentials; +import com.google.cloud.spanner.Database; +import com.google.cloud.spanner.DatabaseAdminClient; +import com.google.cloud.spanner.DatabaseClient; +import com.google.cloud.spanner.DatabaseId; +import com.google.cloud.spanner.Instance; +import com.google.cloud.spanner.InstanceAdminClient; +import com.google.cloud.spanner.InstanceConfigId; +import com.google.cloud.spanner.InstanceId; +import com.google.cloud.spanner.InstanceInfo; +import com.google.cloud.spanner.ResultSet; +import com.google.cloud.spanner.Spanner; +import com.google.cloud.spanner.SpannerOptions; +import com.google.cloud.spanner.Statement; +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.DockerImageName; + +import java.util.Arrays; +import java.util.concurrent.ExecutionException; + +import static org.assertj.core.api.Assertions.assertThat; + +class SpannerEmulatorContainerTest { + + private static final String PROJECT_NAME = "test-project"; + + private static final String INSTANCE_NAME = "test-instance"; + + private static final String DATABASE_NAME = "test-database"; + + // testWithEmulatorContainer { + @Test + void testSimple() throws ExecutionException, InterruptedException { + try ( + // emulatorContainer { + SpannerEmulatorContainer emulator = new SpannerEmulatorContainer( + DockerImageName.parse("gcr.io/cloud-spanner-emulator/emulator:1.4.0") + ); + // } + ) { + emulator.start(); + SpannerOptions options = SpannerOptions + .newBuilder() + .setEmulatorHost(emulator.getEmulatorGrpcEndpoint()) + .setCredentials(NoCredentials.getInstance()) + .setProjectId(PROJECT_NAME) + .build(); + + Spanner spanner = options.getService(); + + InstanceId instanceId = createInstance(spanner); + + createDatabase(spanner); + + DatabaseId databaseId = DatabaseId.of(instanceId, DATABASE_NAME); + DatabaseClient dbClient = spanner.getDatabaseClient(databaseId); + dbClient + .readWriteTransaction() + .run(tx -> { + String sql1 = "Delete from TestTable where 1=1"; + tx.executeUpdate(Statement.of(sql1)); + String sql = "INSERT INTO TestTable (Key, Value) VALUES (1, 'Java'), (2, 'Go')"; + tx.executeUpdate(Statement.of(sql)); + return null; + }); + + ResultSet resultSet = dbClient + .readOnlyTransaction() + .executeQuery(Statement.of("select * from TestTable order by Key")); + resultSet.next(); + assertThat(resultSet.getLong(0)).isEqualTo(1); + assertThat(resultSet.getString(1)).isEqualTo("Java"); + } + } + + // } + + // createDatabase { + private void createDatabase(Spanner spanner) throws InterruptedException, ExecutionException { + DatabaseAdminClient dbAdminClient = spanner.getDatabaseAdminClient(); + Database database = dbAdminClient + .createDatabase( + INSTANCE_NAME, + DATABASE_NAME, + Arrays.asList("CREATE TABLE TestTable (Key INT64, Value STRING(MAX)) PRIMARY KEY (Key)") + ) + .get(); + } + + // } + + // createInstance { + private InstanceId createInstance(Spanner spanner) throws InterruptedException, ExecutionException { + InstanceConfigId instanceConfig = InstanceConfigId.of(PROJECT_NAME, "emulator-config"); + InstanceId instanceId = InstanceId.of(PROJECT_NAME, INSTANCE_NAME); + InstanceAdminClient insAdminClient = spanner.getInstanceAdminClient(); + Instance instance = insAdminClient + .createInstance( + InstanceInfo + .newBuilder(instanceId) + .setNodeCount(1) + .setDisplayName("Test instance") + .setInstanceConfigId(instanceConfig) + .build() + ) + .get(); + return instanceId; + } + // } + +} diff --git a/modules/gcloud/src/test/resources/logback-test.xml b/modules/gcloud/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/gcloud/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/grafana/build.gradle b/modules/grafana/build.gradle new file mode 100644 index 00000000000..93bae9750b4 --- /dev/null +++ b/modules/grafana/build.gradle @@ -0,0 +1,14 @@ +description = "Testcontainers :: Grafana" + +dependencies { + api project(':testcontainers') + + testImplementation 'io.rest-assured:rest-assured:5.5.7' + testImplementation 'io.micrometer:micrometer-registry-otlp:1.17.0' + testImplementation 'uk.org.webcompere:system-stubs-jupiter:2.1.8' + + testImplementation platform('io.opentelemetry:opentelemetry-bom:1.65.0') + testImplementation 'io.opentelemetry:opentelemetry-api' + testImplementation 'io.opentelemetry:opentelemetry-sdk' + testImplementation 'io.opentelemetry:opentelemetry-exporter-otlp' +} diff --git a/modules/grafana/src/main/java/org/testcontainers/grafana/LgtmStackContainer.java b/modules/grafana/src/main/java/org/testcontainers/grafana/LgtmStackContainer.java new file mode 100644 index 00000000000..52443d7ba8a --- /dev/null +++ b/modules/grafana/src/main/java/org/testcontainers/grafana/LgtmStackContainer.java @@ -0,0 +1,81 @@ +package org.testcontainers.grafana; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for Grafana OTel LGTM. + *

+ * Supported image: {@code grafana/otel-lgtm} + *

+ * Exposed ports: + *

    + *
  • Grafana: 3000
  • + *
  • Tempo: 3200
  • + *
  • OTel Http: 4317
  • + *
  • OTel Grpc: 4318
  • + *
  • Prometheus: 9090
  • + *
+ */ +@Slf4j +public class LgtmStackContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("grafana/otel-lgtm"); + + private static final int GRAFANA_PORT = 3000; + + private static final int OTLP_GRPC_PORT = 4317; + + private static final int OTLP_HTTP_PORT = 4318; + + private static final int LOKI_PORT = 3100; + + private static final int TEMPO_PORT = 3200; + + private static final int PROMETHEUS_PORT = 9090; + + public LgtmStackContainer(String image) { + this(DockerImageName.parse(image)); + } + + public LgtmStackContainer(DockerImageName image) { + super(image); + image.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withExposedPorts(GRAFANA_PORT, TEMPO_PORT, LOKI_PORT, OTLP_GRPC_PORT, OTLP_HTTP_PORT, PROMETHEUS_PORT); + waitingFor( + Wait.forLogMessage(".*The OpenTelemetry collector and the Grafana LGTM stack are up and running.*\\s", 1) + ); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + log.info("Access to the Grafana dashboard: {}", getGrafanaHttpUrl()); + } + + public String getOtlpGrpcUrl() { + return "http://" + getHost() + ":" + getMappedPort(OTLP_GRPC_PORT); + } + + public String getTempoUrl() { + return "http://" + getHost() + ":" + getMappedPort(TEMPO_PORT); + } + + public String getLokiUrl() { + return "http://" + getHost() + ":" + getMappedPort(LOKI_PORT); + } + + public String getOtlpHttpUrl() { + return "http://" + getHost() + ":" + getMappedPort(OTLP_HTTP_PORT); + } + + public String getPrometheusHttpUrl() { + return "http://" + getHost() + ":" + getMappedPort(PROMETHEUS_PORT); + } + + public String getGrafanaHttpUrl() { + return "http://" + getHost() + ":" + getMappedPort(GRAFANA_PORT); + } +} diff --git a/modules/grafana/src/test/java/org/testcontainers/grafana/LgtmStackContainerTest.java b/modules/grafana/src/test/java/org/testcontainers/grafana/LgtmStackContainerTest.java new file mode 100644 index 00000000000..ff8d5cb6609 --- /dev/null +++ b/modules/grafana/src/test/java/org/testcontainers/grafana/LgtmStackContainerTest.java @@ -0,0 +1,156 @@ +package org.testcontainers.grafana; + +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.registry.otlp.OtlpConfig; +import io.micrometer.registry.otlp.OtlpMeterRegistry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.exporter.otlp.logs.OtlpGrpcLogRecordExporter; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.logs.export.SimpleLogRecordProcessor; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.restassured.RestAssured; +import io.restassured.response.Response; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import uk.org.webcompere.systemstubs.SystemStubs; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class LgtmStackContainerTest { + + @Test + void shouldPublishMetricsTracesAndLogs() throws Exception { + try ( // container { + LgtmStackContainer lgtm = new LgtmStackContainer("grafana/otel-lgtm:0.11.1") + // } + ) { + lgtm.start(); + + OtlpGrpcSpanExporter spanExporter = OtlpGrpcSpanExporter + .builder() + .setTimeout(Duration.ofSeconds(1)) + .setEndpoint(lgtm.getOtlpGrpcUrl()) + .build(); + + OtlpGrpcLogRecordExporter logExporter = OtlpGrpcLogRecordExporter + .builder() + .setTimeout(Duration.ofSeconds(1)) + .setEndpoint(lgtm.getOtlpGrpcUrl()) + .build(); + + BatchSpanProcessor spanProcessor = BatchSpanProcessor + .builder(spanExporter) + .setScheduleDelay(500, TimeUnit.MILLISECONDS) + .build(); + + SdkTracerProvider tracerProvider = SdkTracerProvider + .builder() + .addSpanProcessor(spanProcessor) + .setResource(Resource.create(Attributes.of(AttributeKey.stringKey("service.name"), "test-service"))) + .build(); + + SdkLoggerProvider loggerProvider = SdkLoggerProvider + .builder() + .addLogRecordProcessor(SimpleLogRecordProcessor.create(logExporter)) + .build(); + + OpenTelemetrySdk openTelemetry = OpenTelemetrySdk + .builder() + .setTracerProvider(tracerProvider) + .setLoggerProvider(loggerProvider) + .build(); + + String version = RestAssured + .get(String.format("http://%s:%s/api/health", lgtm.getHost(), lgtm.getMappedPort(3000))) + .jsonPath() + .get("version"); + assertThat(version).isEqualTo("12.0.0"); + + OtlpConfig otlpConfig = createOtlpConfig(lgtm); + MeterRegistry meterRegistry = SystemStubs + .withEnvironmentVariable("OTEL_SERVICE_NAME", "testcontainers") + .execute(() -> new OtlpMeterRegistry(otlpConfig, Clock.SYSTEM)); + Counter.builder("test.counter").register(meterRegistry).increment(2); + + Logger logger = openTelemetry.getSdkLoggerProvider().loggerBuilder("test").build(); + logger + .logRecordBuilder() + .setBody("Test log!") + .setAttribute(AttributeKey.stringKey("job"), "test-job") + .emit(); + + Tracer tracer = openTelemetry.getTracer("test"); + Span span = tracer.spanBuilder("test").startSpan(); + span.end(); + + Awaitility + .given() + .pollInterval(Duration.ofSeconds(2)) + .atMost(Duration.ofSeconds(5)) + .ignoreExceptions() + .untilAsserted(() -> { + Response metricResponse = RestAssured + .given() + .queryParam("query", "test_counter_total{job=\"testcontainers\"}") + .get(String.format("%s/api/v1/query", lgtm.getPrometheusHttpUrl())) + .prettyPeek() + .thenReturn(); + assertThat(metricResponse.getStatusCode()).isEqualTo(200); + assertThat(metricResponse.body().jsonPath().getList("data.result[0].value")).contains("2"); + + Response logResponse = RestAssured + .given() + .queryParam("query", "{service_name=\"unknown_service:java\"}") + .get(String.format("%s/loki/api/v1/query_range", lgtm.getLokiUrl())) + .prettyPeek() + .thenReturn(); + assertThat(logResponse.getStatusCode()).isEqualTo(200); + assertThat(logResponse.body().jsonPath().getString("data.result[0].values[0][1]")) + .isEqualTo("Test log!"); + + Response traceResponse = RestAssured + .given() + .get(String.format("%s/api/search", lgtm.getTempoUrl())) + .prettyPeek() + .thenReturn(); + assertThat(traceResponse.getStatusCode()).isEqualTo(200); + assertThat(traceResponse.body().jsonPath().getString("traces[0].rootServiceName")) + .isEqualTo("test-service"); + }); + + openTelemetry.close(); + } + } + + private static OtlpConfig createOtlpConfig(LgtmStackContainer lgtm) { + return new OtlpConfig() { + @Override + public String url() { + return String.format("%s/v1/metrics", lgtm.getOtlpHttpUrl()); + } + + @Override + public Duration step() { + return Duration.ofSeconds(1); + } + + @Override + public String get(String s) { + return null; + } + }; + } +} diff --git a/modules/grafana/src/test/resources/logback-test.xml b/modules/grafana/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/grafana/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/hivemq/build.gradle b/modules/hivemq/build.gradle new file mode 100644 index 00000000000..13ec90ff167 --- /dev/null +++ b/modules/hivemq/build.gradle @@ -0,0 +1,32 @@ +description = "Testcontainers :: HiveMQ" + +dependencies { + api(project(":testcontainers")) + api("org.jetbrains:annotations:26.1.0") + + shaded("org.apache.commons:commons-lang3:3.20.0") + shaded("commons-io:commons-io:2.21.0") + shaded("org.javassist:javassist:3.32.0-GA") + shaded("org.jboss.shrinkwrap:shrinkwrap-api:1.2.6") + shaded("org.jboss.shrinkwrap:shrinkwrap-impl-base:1.2.6") + shaded("net.lingala.zip4j:zip4j:2.11.6") + + testImplementation(project(":testcontainers-junit-jupiter")) + testImplementation("com.hivemq:hivemq-extension-sdk:4.50.0") + testImplementation("com.hivemq:hivemq-mqtt-client:1.3.15") + testImplementation("org.apache.httpcomponents:httpclient:4.5.14") + testImplementation("ch.qos.logback:logback-classic:1.5.37") +} + +test { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(11) + } +} + +compileTestJava { + javaCompiler = javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(11) + } + options.release.set(11) +} diff --git a/modules/hivemq/src/main/java/org/testcontainers/hivemq/HiveMQContainer.java b/modules/hivemq/src/main/java/org/testcontainers/hivemq/HiveMQContainer.java new file mode 100644 index 00000000000..241d5b18c9e --- /dev/null +++ b/modules/hivemq/src/main/java/org/testcontainers/hivemq/HiveMQContainer.java @@ -0,0 +1,631 @@ +package org.testcontainers.hivemq; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import org.apache.commons.io.FileUtils; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Testcontainers implementation for HiveMQ. + *

+ * Supported images: {@code hivemq/hivemq4}, {@code hivemq/hivemq-ce} + *

+ * Exposed ports: + *

    + *
  • MQTT: 1883
  • + *
  • Control Center: 8080
  • + *
  • Debug: 9000
  • + *
+ */ +public class HiveMQContainer extends GenericContainer { + + private static final Logger LOGGER = LoggerFactory.getLogger(HiveMQContainer.class); + + private static final DockerImageName DEFAULT_HIVEMQ_EE_IMAGE_NAME = DockerImageName.parse("hivemq/hivemq4"); + + private static final DockerImageName DEFAULT_HIVEMQ_CE_IMAGE_NAME = DockerImageName.parse("hivemq/hivemq-ce"); + + private static final int DEBUGGING_PORT = 9000; + + private static final int MQTT_PORT = 1883; + + private static final int CONTROL_CENTER_PORT = 8080; + + @SuppressWarnings("OctalInteger") + private static final int MODE = 0777; + + @NotNull + private static final Pattern EXTENSION_ID_PATTERN = Pattern.compile("(.+?)"); + + @NotNull + private final ConcurrentHashMap containerOutputLatches = new ConcurrentHashMap<>(); + + private boolean controlCenterEnabled = false; + + private boolean debugging = false; + + @NotNull + private final Set prepackagedExtensionsToRemove = new HashSet<>(); + + private boolean removeAllPrepackagedExtensions = false; + + @NotNull + private final WaitAllStrategy waitStrategy = new WaitAllStrategy(); + + public HiveMQContainer(final @NotNull DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_HIVEMQ_CE_IMAGE_NAME, DEFAULT_HIVEMQ_EE_IMAGE_NAME); + + addExposedPort(MQTT_PORT); + + waitStrategy.withStrategy(Wait.forLogMessage("(.*)Started HiveMQ in(.*)", 1)); + waitingFor(waitStrategy); + + withLogConsumer(outputFrame -> { + final String utf8String = outputFrame.getUtf8String(); + if (debugging && utf8String.startsWith("Listening for transport dt_socket at address:")) { + System.out.println("Listening for transport dt_socket at address: " + getMappedPort(DEBUGGING_PORT)); + } + if (!containerOutputLatches.isEmpty()) { + containerOutputLatches.forEach((regEx, latch) -> { + if (outputFrame.getUtf8String().matches("(?s)" + regEx)) { + LOGGER.debug("Container Output '{}' matched RegEx '{}'", utf8String, regEx); + latch.countDown(); + } else { + LOGGER.debug("Container Output '{}' did not match RegEx '{}'", utf8String, regEx); + } + }); + } + }); + + final HashMap tmpFs = new HashMap<>(); + if (dockerImageName.isCompatibleWith(DEFAULT_HIVEMQ_EE_IMAGE_NAME)) { + tmpFs.put("/opt/hivemq/audit", "rw"); + tmpFs.put("/opt/hivemq/backup", "rw"); + } + + tmpFs.put("/opt/hivemq/log", "rw"); + tmpFs.put("/opt/hivemq/data", "rw"); + withTmpFs(tmpFs); + } + + @Override + protected void configure() { + final String removeCommand; + withCreateContainerCmdModifier(it -> it.withEntrypoint("/bin/sh")); + + if (removeAllPrepackagedExtensions || !prepackagedExtensionsToRemove.isEmpty()) { + if (removeAllPrepackagedExtensions) { + removeCommand = "rm -rf /opt/hivemq/extensions/** &&"; + } else { + removeCommand = + prepackagedExtensionsToRemove + .stream() + .map(extensionId -> "rm -rf /opt/hivemq/extensions/" + extensionId + "&&") + .collect(Collectors.joining()); + } + } else { + removeCommand = ""; + } + setCommand( + "-c", + removeCommand + + "cp -r '/opt/hivemq/temp-extensions/'* /opt/hivemq/extensions/ ; " + + "chmod -R 777 /opt/hivemq/extensions ; " + + "/opt/docker-entrypoint.sh /opt/hivemq/bin/run.sh" + ); + } + + protected void containerIsStarted(final @NotNull InspectContainerResponse containerInfo) { + if (controlCenterEnabled) { + LOGGER.info( + "The HiveMQ Control Center is reachable under: http://{}:{}", + getHost(), + getMappedPort(CONTROL_CENTER_PORT) + ); + } + } + + /** + * Adds a wait condition for the extension with this name. + *

+ * Must be called before the container is started. + * + * @param extensionName the extension to wait for + * @return self + */ + public @NotNull HiveMQContainer waitForExtension(final @NotNull String extensionName) { + final String regEX = "(.*)Extension \"" + extensionName + "\" version (.*) started successfully(.*)"; + waitStrategy.withStrategy(Wait.forLogMessage(regEX, 1)); + return self(); + } + + /** + * Adds a wait condition for this {@link HiveMQExtension} + *

+ * Must be called before the container is started. + * + * @param extension the extension to wait for + * @return self + */ + public @NotNull HiveMQContainer waitForExtension(final @NotNull HiveMQExtension extension) { + return this.waitForExtension(extension.getName()); + } + + /** + * Enables the possibility for remote debugging clients to connect. + *

+ * Must be called before the container is started. + * + * @return self + */ + public @NotNull HiveMQContainer withDebugging() { + debugging = true; + addExposedPorts(DEBUGGING_PORT); + withEnv( + "JAVA_OPTS", + "-agentlib:jdwp=transport=dt_socket,address=0.0.0.0:" + DEBUGGING_PORT + ",server=y,suspend=y" + ); + return self(); + } + + /** + * Sets the logging {@link Level} inside the container. + *

+ * Must be called before the container is started. + * + * @param level the {@link Level} + * @return self + */ + public @NotNull HiveMQContainer withLogLevel(final @NotNull Level level) { + this.withEnv("HIVEMQ_LOG_LEVEL", level.name()); + return self(); + } + + /** + * Wraps the given class and all its subclasses into an extension + * and puts it into '/opt/hivemq/temp-extensions/{extension-id}' inside the container. + *

+ * Must be called before the container is started. + *

+ * The contents of the '/opt/hivemq/temp-extensions/' directory are copied to '/opt/hivemq/extensions/' before the container is started. + * + * @param hiveMQExtension the {@link HiveMQExtension} of the extension + * @return self + */ + public @NotNull HiveMQContainer withExtension(final @NotNull HiveMQExtension hiveMQExtension) { + try { + final File extension = hiveMQExtension.createExtension(hiveMQExtension); + final MountableFile mountableExtension = MountableFile.forHostPath(extension.getPath(), MODE); + withCopyFileToContainer(mountableExtension, "/opt/hivemq/temp-extensions/" + hiveMQExtension.getId()); + } catch (final Exception e) { + throw new ContainerLaunchException(e.getMessage() == null ? "" : e.getMessage(), e); + } + return self(); + } + + /** + * Puts the given extension folder into '/opt/hivemq/temp-extensions/{directory-name}' inside the container. + * It must at least contain a valid hivemq-extension.xml and a valid extension.jar in order to be executed. + * The directory-name is taken from the id defined in the hivemq-extension.xml. + *

+ * Must be called before the container is started. + *

+ * The contents of the '/opt/hivemq/temp-extensions/' directory are copied to '/opt/hivemq/extensions/' before the container is started. + * + * @param mountableExtension the extension folder on the host machine + * @return self + */ + public @NotNull HiveMQContainer withExtension(final @NotNull MountableFile mountableExtension) { + final File extensionDir = new File(mountableExtension.getResolvedPath()); + if (!extensionDir.exists()) { + throw new ContainerLaunchException( + "Extension '" + mountableExtension.getFilesystemPath() + "' could not be mounted. It does not exist." + ); + } + if (!extensionDir.isDirectory()) { + throw new ContainerLaunchException( + "Extension '" + + mountableExtension.getFilesystemPath() + + "' could not be mounted. It is not a directory." + ); + } + try { + final String extensionDirName = getExtensionDirectoryName(extensionDir); + final String containerPath = "/opt/hivemq/temp-extensions/" + extensionDirName; + withCopyFileToContainer(cloneWithFileMode(mountableExtension), containerPath); + LOGGER.info("Putting extension '{}' into '{}'", extensionDirName, containerPath); + } catch (final Exception e) { + throw new ContainerLaunchException(e.getMessage() == null ? "" : e.getMessage(), e); + } + return self(); + } + + private @NotNull String getExtensionDirectoryName(final @NotNull File extensionDirectory) throws IOException { + final File file = new File(extensionDirectory, "hivemq-extension.xml"); + final String xml = FileUtils.readFileToString(file, StandardCharsets.UTF_8); + final Matcher matcher = EXTENSION_ID_PATTERN.matcher(xml); + + if (!matcher.find()) { + throw new IllegalStateException("Could not parse extension id from '" + file.getAbsolutePath() + "'"); + } + return matcher.group(1); + } + + /** + * Removes the specified prepackaged extension folders from '/opt/hivemq/extensions' before the container is started. + *

+ * Must be called before the container is started. + * + * @param extensionIds the prepackaged extensions to remove + * @return self + */ + public @NotNull HiveMQContainer withoutPrepackagedExtensions(final @NotNull String... extensionIds) { + Collections.addAll(prepackagedExtensionsToRemove, extensionIds); + return self(); + } + + /** + * Removes all prepackaged extension folders from '/opt/hivemq/extensions' before the container is started. + *

+ * Must be called before the container is started. + * + * @return self + */ + public @NotNull HiveMQContainer withoutPrepackagedExtensions() { + removeAllPrepackagedExtensions = true; + return self(); + } + + /** + * Puts the given license into '/opt/hivemq/license/' inside the container. + * It must end with '.lic' or '.elic'. + *

+ * Must be called before the container is started. + * + * @param mountableLicense the license file on the host machine + * @return self + */ + public @NotNull HiveMQContainer withLicense(final @NotNull MountableFile mountableLicense) { + final File licenseFile = new File(mountableLicense.getResolvedPath()); + if (!licenseFile.exists()) { + throw new ContainerLaunchException( + "License file '" + mountableLicense.getFilesystemPath() + "' does not exist." + ); + } + if (!licenseFile.getName().endsWith(".lic") && !licenseFile.getName().endsWith(".elic")) { + throw new ContainerLaunchException( + "License file '" + mountableLicense.getFilesystemPath() + "' does not end wit '.lic' or '.elic'." + ); + } + final String containerPath = "/opt/hivemq/license/" + licenseFile.getName(); + withCopyFileToContainer(cloneWithFileMode(mountableLicense), containerPath); + LOGGER.info("Putting license '{}' into '{}'.", licenseFile.getAbsolutePath(), containerPath); + return self(); + } + + /** + * Overwrites the HiveMQ configuration in '/opt/hivemq/conf/' inside the container. + *

+ * Must be called before the container is started. + * + * @param mountableConfig the config file on the host machine + * @return self + */ + public @NotNull HiveMQContainer withHiveMQConfig(final @NotNull MountableFile mountableConfig) { + final File config = new File(mountableConfig.getResolvedPath()); + if (!config.exists()) { + throw new ContainerLaunchException( + "HiveMQ config file '" + mountableConfig.getFilesystemPath() + "' does not exist." + ); + } + final String containerPath = "/opt/hivemq/conf/config.xml"; + withCopyFileToContainer(cloneWithFileMode(mountableConfig), containerPath); + LOGGER.info("Putting '{}' into '{}'.", config.getAbsolutePath(), containerPath); + return self(); + } + + /** + * Puts the given file into the root of the extension's home '/opt/hivemq/temp-extensions/{extensionId}/'. + *

+ * Must be called before the container is started. + *

+ * The contents of the '/opt/hivemq/temp-extensions/' directory are copied to '/opt/hivemq/extensions/' before the container is started. + * + * @param file the file on the host machine + * @param extensionId the extension + * @return self + */ + public @NotNull HiveMQContainer withFileInExtensionHomeFolder( + final @NotNull MountableFile file, + final @NotNull String extensionId + ) { + return withFileInExtensionHomeFolder(file, extensionId, ""); + } + + /** + * Puts the given file into given subdirectory of the extensions's home '/opt/hivemq/temp-extensions/{id}/{pathInExtensionHome}/' + *

+ * Must be called before the container is started. + *

+ * The contents of the '/opt/hivemq/temp-extensions/' directory are copied to '/opt/hivemq/extensions/' before the container is started. + * + * @param file the file on the host machine + * @param extensionId the extension + * @param pathInExtensionHome the path + * @return self + */ + public @NotNull HiveMQContainer withFileInExtensionHomeFolder( + final @NotNull MountableFile file, + final @NotNull String extensionId, + final @NotNull String pathInExtensionHome + ) { + return withFileInHomeFolder( + file, + "/temp-extensions/" + extensionId + PathUtil.prepareAppendPath(pathInExtensionHome) + ); + } + + /** + * Puts the given file into the given subdirectory of the HiveMQ home folder '/opt/hivemq/{pathInHomeFolder}'. + *

+ * Must be called before the container is started. + * + * @param mountableFile the file on the host machine + * @param pathInHomeFolder the path + * @return self + */ + public @NotNull HiveMQContainer withFileInHomeFolder( + final @NotNull MountableFile mountableFile, + final @NotNull String pathInHomeFolder + ) { + final File file = new File(mountableFile.getResolvedPath()); + + if (pathInHomeFolder.trim().isEmpty()) { + throw new ContainerLaunchException("pathInHomeFolder must not be empty"); + } + + if (!file.exists()) { + throw new ContainerLaunchException("File '" + mountableFile.getFilesystemPath() + "' does not exist."); + } + final String containerPath = "/opt/hivemq" + PathUtil.prepareAppendPath(pathInHomeFolder); + withCopyFileToContainer(cloneWithFileMode(mountableFile), containerPath); + LOGGER.info("Putting file '{}' into container path '{}'.", file.getAbsolutePath(), containerPath); + return self(); + } + + /** + * Disables the extension with the given name and extension directory name. + * This method blocks until the HiveMQ log for successful disabling is consumed or it times out after {timeOut}. + * Note: Disabling Extensions is a HiveMQ Enterprise feature, it will not work when using the HiveMQ Community Edition. + *

+ * This can only be called once the container is started. + * + * @param extensionName the name of the extension to disable + * @param extensionDirectory the name of the extension's directory + * @param timeout the timeout + * @throws TimeoutException if the extension was not disabled within the configured timeout + */ + public void disableExtension( + final @NotNull String extensionName, + final @NotNull String extensionDirectory, + final @NotNull Duration timeout + ) throws TimeoutException { + final String regEX = "(.*)Extension \"" + extensionName + "\" version (.*) stopped successfully(.*)"; + try { + final String containerPath = + "/opt/hivemq/extensions" + PathUtil.prepareInnerPath(extensionDirectory) + "DISABLED"; + + final CountDownLatch latch = new CountDownLatch(1); + containerOutputLatches.put(regEX, latch); + + execInContainer("touch", containerPath); + LOGGER.info("Putting DISABLED file into container path '{}'", containerPath); + + final boolean await = latch.await(timeout.getSeconds(), TimeUnit.SECONDS); + if (!await) { + throw new TimeoutException( + "Extension disabling timed out after '" + + timeout.getSeconds() + + "' seconds. " + + "Maybe you are using a HiveMQ Community Edition image, " + + "which does not support disabling of extensions" + ); + } + } catch (final InterruptedException | IOException e) { + throw new RuntimeException(e); + } finally { + containerOutputLatches.remove(regEX); + } + } + + /** + * Disables the extension with the given name and extension directory name. + * This method blocks until the HiveMQ log for successful disabling is consumed or it times out after 60 seconds. + * Note: Disabling Extensions is a HiveMQ Enterprise feature, it will not work when using the HiveMQ Community Edition. + *

+ * This can only be called once the container is started. + * + * @param extensionName the name of the extension to disable + * @param extensionDirectory the name of the extension's directory + * @throws TimeoutException if the extension was not disabled within 60 seconds + */ + public void disableExtension(final @NotNull String extensionName, final @NotNull String extensionDirectory) + throws TimeoutException { + disableExtension(extensionName, extensionDirectory, Duration.ofSeconds(60)); + } + + /** + * Disables the extension. + * This method blocks until the HiveMQ log for successful disabling is consumed or it times out after {timeOut}. + * Note: Disabling Extensions is a HiveMQ Enterprise feature, it will not work when using the HiveMQ Community Edition. + *

+ * This can only be called once the container is started. + * + * @param hiveMQExtension the extension + * @param timeout the timeout + * @throws TimeoutException if the extension was not disabled within the configured timeout + */ + public void disableExtension(final @NotNull HiveMQExtension hiveMQExtension, final @NotNull Duration timeout) + throws TimeoutException { + disableExtension(hiveMQExtension.getName(), hiveMQExtension.getId(), timeout); + } + + /** + * Disables the extension. + * This method blocks until the HiveMQ log for successful disabling is consumed or it times out after 60 seconds. + * Note: Disabling Extensions is a HiveMQ Enterprise feature, it will not work when using the HiveMQ Community Edition. + *

+ * This can only be called once the container is started. + * + * @param hiveMQExtension the extension + * @throws TimeoutException if the extension was not disabled within 60 seconds + */ + public void disableExtension(final @NotNull HiveMQExtension hiveMQExtension) throws TimeoutException { + disableExtension(hiveMQExtension, Duration.ofSeconds(60)); + } + + /** + * Enables the extension with the given name and extension directory name. + * This method blocks until the HiveMQ log for successful enabling is consumed or it times out after {timeOut}. + * Note: Enabling Extensions is a HiveMQ Enterprise feature, it will not work when using the HiveMQ Community Edition. + *

+ * This can only be called once the container is started. + * + * @param extensionName the name of the extension to disable + * @param extensionDirectory the name of the extension's directory + * @param timeout the timeout + * @throws TimeoutException if the extension was not enabled within the configured timeout + */ + public void enableExtension( + final @NotNull String extensionName, + final @NotNull String extensionDirectory, + final @NotNull Duration timeout + ) throws TimeoutException { + final String regEX = "(.*)Extension \"" + extensionName + "\" version (.*) started successfully(.*)"; + try { + final String containerPath = + "/opt/hivemq/extensions" + PathUtil.prepareInnerPath(extensionDirectory) + "DISABLED"; + + final CountDownLatch latch = new CountDownLatch(1); + containerOutputLatches.put(regEX, latch); + + execInContainer("rm", "-rf", containerPath); + LOGGER.info("Removing DISABLED file in container path '{}'", containerPath); + + final boolean await = latch.await(timeout.getSeconds(), TimeUnit.SECONDS); + if (!await) { + throw new TimeoutException( + "Extension enabling timed out after '" + + timeout.getSeconds() + + "' seconds. " + + "Maybe you are using a HiveMQ Community Edition image, " + + "which does not support disabling of extensions" + ); + } + } catch (final InterruptedException | IOException e) { + throw new RuntimeException(e); + } finally { + containerOutputLatches.remove(regEX); + } + } + + /** + * Enables the extension with the given name and extension directory name. + * This method blocks until the HiveMQ log for successful enabling is consumed or it times out after 60 seconds. + * Note: Enabling Extensions is a HiveMQ Enterprise feature, it will not work when using the HiveMQ Community Edition. + *

+ * This can only be called once the container is started. + * + * @param extensionName the name of the extension to disable + * @param extensionDirectory the name of the extension's directory + * @throws TimeoutException if the extension was not enabled within 60 seconds + */ + public void enableExtension(final @NotNull String extensionName, final @NotNull String extensionDirectory) + throws TimeoutException { + enableExtension(extensionName, extensionDirectory, Duration.ofSeconds(60)); + } + + /** + * Enables the extension. + * This method blocks until the HiveMQ log for successful enabling is consumed or it times out after {timeOut}. + * Note: Enabling Extensions is a HiveMQ Enterprise feature, it will not work when using the HiveMQ Community Edition. + *

+ * This can only be called once the container is started. + * + * @param hiveMQExtension the extension + * @param timeout the timeout + * @throws TimeoutException if the extension was not enabled within the configured timeout + */ + public void enableExtension(final @NotNull HiveMQExtension hiveMQExtension, final @NotNull Duration timeout) + throws TimeoutException { + enableExtension(hiveMQExtension.getName(), hiveMQExtension.getId(), timeout); + } + + /** + * Enables the extension. + * This method blocks until the HiveMQ log for successful enabling is consumed or it times out after {timeOut}. + * Note: Enabling Extensions is a HiveMQ Enterprise feature, it will not work when using the HiveMQ Community Edition. + *

+ * This can only be called once the container is started. + * + * @param hiveMQExtension the extension + * @throws TimeoutException if the extension was not enabled within 60 seconds + */ + public void enableExtension(final @NotNull HiveMQExtension hiveMQExtension) throws TimeoutException { + enableExtension(hiveMQExtension, Duration.ofSeconds(60)); + } + + /** + * Enables connection to the HiveMQ Control Center on host port 8080. + * Note: the control center is a HiveMQ 4 Enterprise feature. + *

+ * Must be called before the container is started. + * + * @return self + */ + public @NotNull HiveMQContainer withControlCenter() { + addExposedPorts(CONTROL_CENTER_PORT); + controlCenterEnabled = true; + return self(); + } + + /** + * Get the mapped port for the MQTT port of the container. + *

+ * Must be called after the container is started. + * + * @return the port on the host machine for mqtt clients to connect + */ + public int getMqttPort() { + return this.getMappedPort(MQTT_PORT); + } + + private @NotNull MountableFile cloneWithFileMode(final @NotNull MountableFile mountableFile) { + return MountableFile.forHostPath(mountableFile.getResolvedPath(), HiveMQContainer.MODE); + } +} diff --git a/modules/hivemq/src/main/java/org/testcontainers/hivemq/HiveMQExtension.java b/modules/hivemq/src/main/java/org/testcontainers/hivemq/HiveMQExtension.java new file mode 100644 index 00000000000..91e984835d6 --- /dev/null +++ b/modules/hivemq/src/main/java/org/testcontainers/hivemq/HiveMQExtension.java @@ -0,0 +1,329 @@ +package org.testcontainers.hivemq; + +import lombok.Getter; +import org.apache.commons.io.FileUtils; +import org.jboss.shrinkwrap.api.ExtensionLoader; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.exporter.ZipExporter; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.jboss.shrinkwrap.impl.base.exporter.zip.ZipExporterImpl; +import org.jboss.shrinkwrap.impl.base.spec.JavaArchiveImpl; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.ContainerLaunchException; + +import java.io.File; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import javassist.ClassPool; +import javassist.NotFoundException; + +public class HiveMQExtension { + + private static final String VALID_EXTENSION_XML = + "" + // + " %s" + // + " %s" + // + " %s" + // + " %s" + // + " %s" + // + ""; + + private static final String EXTENSION_MAIN_CLASS_NAME = "com.hivemq.extension.sdk.api.ExtensionMain"; + + private static final Logger LOGGER = LoggerFactory.getLogger(HiveMQExtension.class); + + @Getter + @NotNull + private final String id; + + @Getter + @NotNull + private final String name; + + @Getter + @NotNull + private final String version; + + @Getter + private final int priority; + + @Getter + private final int startPriority; + + @Getter + private final boolean disabledOnStartup; + + @Getter + @NotNull + private final Class mainClass; + + @NotNull + private final List> additionalClasses; + + private HiveMQExtension( + final @NotNull String id, + final @NotNull String name, + final @NotNull String version, + final int priority, + final int startPriority, + final boolean disabledOnStartup, + final @NotNull Class mainClass, + final @NotNull List> additionalClasses + ) { + this.id = id; + this.name = name; + this.version = version; + this.priority = priority; + this.startPriority = startPriority; + this.disabledOnStartup = disabledOnStartup; + this.mainClass = mainClass; + this.additionalClasses = additionalClasses; + } + + @NotNull + File createExtension(final @NotNull HiveMQExtension hiveMQExtension) throws Exception { + final File tempDir = Files.createTempDirectory("").toFile(); + + final File extensionDir = new File(tempDir, hiveMQExtension.getId()); + FileUtils.writeStringToFile( + new File(extensionDir, "hivemq-extension.xml"), + String.format( + VALID_EXTENSION_XML, + hiveMQExtension.getId(), + hiveMQExtension.getName(), + hiveMQExtension.getVersion(), + hiveMQExtension.getPriority(), + hiveMQExtension.getStartPriority() + ), + Charset.defaultCharset() + ); + + if (hiveMQExtension.isDisabledOnStartup()) { + final File disabled = new File(extensionDir, "DISABLED"); + final boolean newFile = disabled.createNewFile(); + if (!newFile) { + throw new ContainerLaunchException( + "Could not create DISABLED file '" + disabled.getAbsolutePath() + "' on host machine." + ); + } + } + + // Shadow Gradle plugin doesn't know how to handle ShrinkWrap's SPI definitions + // This workaround creates the mappings programmatically + // TODO write a custom Gradle Shadow transformer? + ExtensionLoader extensionLoader = ShrinkWrap.getDefaultDomain().getConfiguration().getExtensionLoader(); + extensionLoader.addOverride(JavaArchive.class, JavaArchiveImpl.class); + extensionLoader.addOverride(ZipExporter.class, ZipExporterImpl.class); + + final JavaArchive javaArchive = ShrinkWrap + .create(JavaArchive.class) + .addAsServiceProvider(EXTENSION_MAIN_CLASS_NAME, hiveMQExtension.getMainClass().getName()); + + putSubclassesIntoJar(hiveMQExtension.getId(), hiveMQExtension.getMainClass(), javaArchive); + for (final Class additionalClass : hiveMQExtension.getAdditionalClasses()) { + javaArchive.addClass(additionalClass); + putSubclassesIntoJar(hiveMQExtension.getId(), additionalClass, javaArchive); + } + + javaArchive.as(ZipExporter.class).exportTo(new File(extensionDir, "extension.jar")); + + return extensionDir; + } + + private void putSubclassesIntoJar( + final @NotNull String extensionId, + final @Nullable Class clazz, + final @NotNull JavaArchive javaArchive + ) throws NotFoundException { + if (clazz != null) { + final Set subClassNames = ClassPool + .getDefault() + .get(clazz.getName()) + .getClassFile() + .getConstPool() + .getClassNames(); + for (final String subClassName : subClassNames) { + final String className = subClassName.replaceAll("/", "."); + + if (!className.startsWith("[L")) { + LOGGER.debug("Trying to package subclass '{}' into extension '{}'.", className, extensionId); + javaArchive.addClass(className); + } else { + LOGGER.debug("Class '{}' will be ignored.", className); + } + } + } + } + + public @NotNull List> getAdditionalClasses() { + return Collections.unmodifiableList(additionalClasses); + } + + public static @NotNull Builder builder() { + return new Builder(); + } + + public static final class Builder { + + @Nullable + private String id; + + @Nullable + private String name; + + @Nullable + private String version; + + private int priority = 0; + + private int startPriority = 0; + + private boolean disabledOnStartup = false; + + @Nullable + private Class mainClass; + + @NotNull + private final LinkedList> additionalClasses = new LinkedList<>(); + + /** + * Builds the {@link HiveMQExtension} with the provided values or default values. + * @return the HiveMQ Extension + */ + public @NotNull HiveMQExtension build() { + if (id == null || id.isEmpty()) { + throw new IllegalArgumentException("extension id must not be null or empty"); + } + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("extension name must not be null or empty"); + } + if (version == null || version.isEmpty()) { + throw new IllegalArgumentException("extension version must not be null or empty"); + } + if (mainClass == null) { + throw new IllegalArgumentException("extension main class must not be null"); + } + return new HiveMQExtension( + id, + name, + version, + priority, + startPriority, + disabledOnStartup, + mainClass, + additionalClasses + ); + } + + /** + * Sets the identifier of the {@link HiveMQExtension}. + * + * @param id the identifier, must not be empty + * @return the {@link Builder} + */ + public @NotNull Builder id(final @NotNull String id) { + this.id = id; + return this; + } + + /** + * Sets the name of the {@link HiveMQExtension}. + * + * @param name the identifier, must not be empty + * @return the {@link Builder} + */ + public @NotNull Builder name(final @NotNull String name) { + this.name = name; + return this; + } + + /** + * Sets the version of the {@link HiveMQExtension}. + * + * @param version the version, must not be empty + * @return the {@link Builder} + */ + public @NotNull Builder version(final @NotNull String version) { + this.version = version; + return this; + } + + /** + * Sets the priority of the {@link HiveMQExtension}. + * + * @param priority the priority + * @return the {@link Builder} + */ + public @NotNull Builder priority(final int priority) { + this.priority = priority; + return this; + } + + /** + * Sets the start-priority of the {@link HiveMQExtension}. + * + * @param startPriority the start-priority + * @return the {@link Builder} + */ + public @NotNull Builder startPriority(final int startPriority) { + this.startPriority = startPriority; + return this; + } + + /** + * Flag, that indicates whether the {@link HiveMQExtension} should be disabled when HiveMQ starts. + * Disabling on startup is achieved by placing a DISABLED file in the {@link HiveMQExtension}'s directory before coping it to the container. + * + * @param disabledOnStartup if the {@link HiveMQExtension} should be disabled when HiveMQ starts + * @return the {@link Builder} + */ + public @NotNull Builder disabledOnStartup(final boolean disabledOnStartup) { + this.disabledOnStartup = disabledOnStartup; + return this; + } + + /** + * The main class of the {@link HiveMQExtension}. + * This class MUST implement com.hivemq.extension.sdk.api.ExtensionMain. + * + * @param mainClass the main class + * @return the {@link Builder} + * @throws IllegalArgumentException if the provides class does not implement com.hivemq.extension.sdk.api.ExtensionMain} + * @throws IllegalStateException if com.hivemq.extension.sdk.api.ExtensionMain is not found in the classpath + */ + public @NotNull Builder mainClass(final @NotNull Class mainClass) { + try { + final Class extensionMain = Class.forName(EXTENSION_MAIN_CLASS_NAME); + if (!extensionMain.isAssignableFrom(mainClass)) { + throw new IllegalArgumentException( + "The provided class does not implement '" + EXTENSION_MAIN_CLASS_NAME + "'" + ); + } + this.mainClass = mainClass; + return this; + } catch (final ClassNotFoundException e) { + throw new IllegalStateException( + "The class '" + EXTENSION_MAIN_CLASS_NAME + "' was not found in the classpath." + ); + } + } + + /** + * Adds an additional class to the .jar file of the {@link HiveMQExtension}. + * + * @param clazz the additional class + * @return the {@link Builder} + */ + public @NotNull Builder addAdditionalClass(final @NotNull Class clazz) { + this.additionalClasses.add(clazz); + return this; + } + } +} diff --git a/modules/hivemq/src/main/java/org/testcontainers/hivemq/PathUtil.java b/modules/hivemq/src/main/java/org/testcontainers/hivemq/PathUtil.java new file mode 100755 index 00000000000..245052f46b8 --- /dev/null +++ b/modules/hivemq/src/main/java/org/testcontainers/hivemq/PathUtil.java @@ -0,0 +1,26 @@ +package org.testcontainers.hivemq; + +import org.jetbrains.annotations.NotNull; + +class PathUtil { + + static @NotNull String prepareInnerPath(@NotNull String innerPath) { + if ("/".equals(innerPath) || innerPath.isEmpty()) { + return "/"; + } + if (!innerPath.startsWith("/")) { + innerPath = "/" + innerPath; + } + if (!innerPath.endsWith("/")) { + innerPath += "/"; + } + return innerPath; + } + + static @NotNull String prepareAppendPath(@NotNull String appendPath) { + if (!appendPath.startsWith("/")) { + appendPath = "/" + appendPath; + } + return appendPath; + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithControlCenterIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithControlCenterIT.java new file mode 100644 index 00000000000..6d122d824fd --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithControlCenterIT.java @@ -0,0 +1,34 @@ +package org.testcontainers.hivemq; + +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.utility.DockerImageName; + +import java.util.concurrent.TimeUnit; + +class ContainerWithControlCenterIT { + + public static final int CONTROL_CENTER_PORT = 8080; + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test() throws Exception { + try ( + final HiveMQContainer hivemq = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq4").withTag("4.7.4")) + .withControlCenter() + ) { + hivemq.start(); + + try (final CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { + final HttpUriRequest request = new HttpGet( + "http://" + hivemq.getHost() + ":" + hivemq.getMappedPort(CONTROL_CENTER_PORT) + ); + httpClient.execute(request); + } + } + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithCustomConfigIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithCustomConfigIT.java new file mode 100755 index 00000000000..aed446db203 --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithCustomConfigIT.java @@ -0,0 +1,43 @@ +package org.testcontainers.hivemq; + +import com.hivemq.client.mqtt.datatypes.MqttQos; +import com.hivemq.client.mqtt.exceptions.MqttSessionExpiredException; +import com.hivemq.client.mqtt.mqtt5.Mqtt5BlockingClient; +import com.hivemq.client.mqtt.mqtt5.Mqtt5Client; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class ContainerWithCustomConfigIT { + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test() throws Exception { + try ( + final HiveMQContainer hivemq = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq4").withTag("4.7.4")) + .withHiveMQConfig(MountableFile.forClasspathResource("/config.xml")) + ) { + hivemq.start(); + + final Mqtt5BlockingClient publisher = Mqtt5Client + .builder() + .identifier("publisher") + .serverPort(hivemq.getMqttPort()) + .serverHost(hivemq.getHost()) + .buildBlocking(); + + publisher.connect(); + + assertThatExceptionOfType(MqttSessionExpiredException.class) + .isThrownBy(() -> { + // this should fail since only QoS 0 is allowed by the configuration + publisher.publishWith().topic("test/topic").qos(MqttQos.EXACTLY_ONCE).send(); + }); + } + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithExtensionFromDirectoryIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithExtensionFromDirectoryIT.java new file mode 100755 index 00000000000..a5b6170a228 --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithExtensionFromDirectoryIT.java @@ -0,0 +1,55 @@ +package org.testcontainers.hivemq; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.event.Level; +import org.testcontainers.hivemq.util.TestPublishModifiedUtil; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.util.concurrent.TimeUnit; + +class ContainerWithExtensionFromDirectoryIT { + + @ParameterizedTest + @ValueSource( + strings = { + "2020.1", // first version that provided a container image + "2024.3", // version that runs the image as a non-root user by default + } + ) + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test(final @NotNull String hivemqCeTag) throws Exception { + try ( + final HiveMQContainer hivemq = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq-ce").withTag(hivemqCeTag) + ) + .withExtension(MountableFile.forClasspathResource("/modifier-extension")) + .waitForExtension("Modifier Extension") + .withHiveMQConfig(MountableFile.forClasspathResource("/inMemoryConfig.xml")) + .withLogLevel(Level.DEBUG) + ) { + hivemq.start(); + TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost()); + } + } + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test_wrongDirectoryName() throws Exception { + try ( + final HiveMQContainer hivemq = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq-ce").withTag("2024.3") + ) + .withExtension(MountableFile.forClasspathResource("/modifier-extension-wrong-name")) + .waitForExtension("Modifier Extension") + .withLogLevel(Level.DEBUG) + ) { + hivemq.start(); + TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost()); + } + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithExtensionIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithExtensionIT.java new file mode 100755 index 00000000000..2b255912da9 --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithExtensionIT.java @@ -0,0 +1,53 @@ +package org.testcontainers.hivemq; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.testcontainers.hivemq.util.MyExtension; +import org.testcontainers.hivemq.util.TestPublishModifiedUtil; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.util.concurrent.TimeUnit; + +class ContainerWithExtensionIT { + + @ParameterizedTest + @ValueSource( + strings = { + "2020.1", // first version that provided a container image + "2024.3", // version that runs the image as a non-root user by default + } + ) + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test(final @NotNull String hivemqCeTag) throws Exception { + final HiveMQExtension hiveMQExtension = HiveMQExtension + .builder() + .id("extension-1") + .name("my-extension") + .version("1.0") + .mainClass(MyExtension.class) + .build(); + + try ( + final HiveMQContainer hivemq = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq-ce").withTag(hivemqCeTag) + ) + .withHiveMQConfig(MountableFile.forClasspathResource("/inMemoryConfig.xml")) + .waitForExtension(hiveMQExtension) + .withExtension(hiveMQExtension) + ) { + hivemq.start(); + TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost()); + hivemq.stop(); + + hivemq.start(); + TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost()); + hivemq.stop(); + + hivemq.start(); + TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost()); + } + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithExtensionSubclassIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithExtensionSubclassIT.java new file mode 100755 index 00000000000..a907b8f2d2e --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithExtensionSubclassIT.java @@ -0,0 +1,39 @@ +package org.testcontainers.hivemq; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.slf4j.event.Level; +import org.testcontainers.hivemq.util.MyExtensionWithSubclasses; +import org.testcontainers.hivemq.util.TestPublishModifiedUtil; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.util.concurrent.TimeUnit; + +class ContainerWithExtensionSubclassIT { + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test() throws Exception { + final HiveMQExtension hiveMQExtension = HiveMQExtension + .builder() + .id("extension-1") + .name("my-extension") + .version("1.0") + .mainClass(MyExtensionWithSubclasses.class) + .build(); + + try ( + final HiveMQContainer hivemq = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq-ce").withTag("2024.3") + ) + .waitForExtension(hiveMQExtension) + .withExtension(hiveMQExtension) + .withHiveMQConfig(MountableFile.forClasspathResource("/inMemoryConfig.xml")) + .withLogLevel(Level.DEBUG) + ) { + hivemq.start(); + TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost()); + } + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithFileInExtensionHomeIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithFileInExtensionHomeIT.java new file mode 100755 index 00000000000..c3c74b0dce7 --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithFileInExtensionHomeIT.java @@ -0,0 +1,93 @@ +package org.testcontainers.hivemq; + +import com.hivemq.extension.sdk.api.ExtensionMain; +import com.hivemq.extension.sdk.api.interceptor.publish.PublishInboundInterceptor; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartOutput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopOutput; +import com.hivemq.extension.sdk.api.services.Services; +import com.hivemq.extension.sdk.api.services.intializer.ClientInitializer; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.testcontainers.hivemq.util.TestPublishModifiedUtil; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.File; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +class ContainerWithFileInExtensionHomeIT { + + @ParameterizedTest + @ValueSource( + strings = { + "2020.1", // first version that provided a container image + "2024.3", // version that runs the image as a non-root user by default + } + ) + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test(final @NotNull String hivemqCeTag) throws Exception { + final HiveMQExtension hiveMQExtension = HiveMQExtension + .builder() + .id("extension-1") + .name("my-extension") + .version("1.0") + .mainClass(FileCheckerExtension.class) + .build(); + + try ( + final HiveMQContainer hivemq = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq-ce").withTag(hivemqCeTag) + ) + .withHiveMQConfig(MountableFile.forClasspathResource("/inMemoryConfig.xml")) + .withExtension(hiveMQExtension) + .waitForExtension(hiveMQExtension) + .withFileInExtensionHomeFolder( + MountableFile.forClasspathResource("/additionalFile.txt"), + "extension-1", + "/additionalFiles/my-file.txt" + ) + ) { + hivemq.start(); + TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost()); + } + } + + public static class FileCheckerExtension implements ExtensionMain { + + @Override + public void extensionStart( + final @NotNull ExtensionStartInput extensionStartInput, + final @NotNull ExtensionStartOutput extensionStartOutput + ) { + final PublishInboundInterceptor publishInboundInterceptor = (publishInboundInput, publishInboundOutput) -> { + final File extensionHomeFolder = extensionStartInput.getExtensionInformation().getExtensionHomeFolder(); + + final File additionalFile = new File(extensionHomeFolder, "additionalFiles/my-file.txt"); + + if (additionalFile.exists()) { + publishInboundOutput + .getPublishPacket() + .setPayload(ByteBuffer.wrap("modified".getBytes(StandardCharsets.UTF_8))); + } + }; + + final ClientInitializer clientInitializer = (initializerInput, clientContext) -> { + clientContext.addPublishInboundInterceptor(publishInboundInterceptor); + }; + + Services.initializerRegistry().setClientInitializer(clientInitializer); + } + + @Override + public void extensionStop( + final @NotNull ExtensionStopInput extensionStopInput, + final @NotNull ExtensionStopOutput extensionStopOutput + ) {} + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithFileInHomeIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithFileInHomeIT.java new file mode 100755 index 00000000000..c8e19316788 --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithFileInHomeIT.java @@ -0,0 +1,85 @@ +package org.testcontainers.hivemq; + +import com.hivemq.extension.sdk.api.ExtensionMain; +import com.hivemq.extension.sdk.api.interceptor.publish.PublishInboundInterceptor; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartOutput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopOutput; +import com.hivemq.extension.sdk.api.services.Services; +import com.hivemq.extension.sdk.api.services.intializer.ClientInitializer; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.hivemq.util.TestPublishModifiedUtil; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.File; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +class ContainerWithFileInHomeIT { + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test() throws Exception { + final HiveMQExtension hiveMQExtension = HiveMQExtension + .builder() + .id("extension-1") + .name("my-extension") + .version("1.0") + .mainClass(FileCheckerExtension.class) + .build(); + + try ( + final HiveMQContainer hivemq = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq-ce").withTag("2024.3") + ) + .withHiveMQConfig(MountableFile.forClasspathResource("/inMemoryConfig.xml")) + .withExtension(hiveMQExtension) + .waitForExtension(hiveMQExtension) + .withFileInHomeFolder( + MountableFile.forClasspathResource("/additionalFile.txt"), + "/additionalFiles/my-file.txt" + ) + ) { + hivemq.start(); + TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost()); + } + } + + public static class FileCheckerExtension implements ExtensionMain { + + @Override + public void extensionStart( + @NotNull ExtensionStartInput extensionStartInput, + @NotNull ExtensionStartOutput extensionStartOutput + ) { + final PublishInboundInterceptor publishInboundInterceptor = (publishInboundInput, publishInboundOutput) -> { + final File homeFolder = extensionStartInput.getServerInformation().getHomeFolder(); + + final File additionalFile = new File(homeFolder, "additionalFiles/my-file.txt"); + + if (additionalFile.exists()) { + publishInboundOutput + .getPublishPacket() + .setPayload(ByteBuffer.wrap("modified".getBytes(StandardCharsets.UTF_8))); + } + }; + + final ClientInitializer clientInitializer = (initializerInput, clientContext) -> { + clientContext.addPublishInboundInterceptor(publishInboundInterceptor); + }; + + Services.initializerRegistry().setClientInitializer(clientInitializer); + } + + @Override + public void extensionStop( + @NotNull ExtensionStopInput extensionStopInput, + @NotNull ExtensionStopOutput extensionStopOutput + ) {} + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithLicenseIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithLicenseIT.java new file mode 100755 index 00000000000..3973abddb0b --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithLicenseIT.java @@ -0,0 +1,84 @@ +package org.testcontainers.hivemq; + +import com.hivemq.extension.sdk.api.ExtensionMain; +import com.hivemq.extension.sdk.api.interceptor.publish.PublishInboundInterceptor; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartOutput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopOutput; +import com.hivemq.extension.sdk.api.services.Services; +import com.hivemq.extension.sdk.api.services.intializer.ClientInitializer; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.hivemq.util.TestPublishModifiedUtil; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.File; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +class ContainerWithLicenseIT { + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test() throws Exception { + final HiveMQExtension hiveMQExtension = HiveMQExtension + .builder() + .id("extension-1") + .name("my-extension") + .version("1.0") + .mainClass(LicenceCheckerExtension.class) + .build(); + + try ( + final HiveMQContainer hivemq = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq-ce").withTag("2024.3") + ) + .withHiveMQConfig(MountableFile.forClasspathResource("/inMemoryConfig.xml")) + .withExtension(hiveMQExtension) + .waitForExtension(hiveMQExtension) + .withLicense(MountableFile.forClasspathResource("/myLicense.lic")) + .withLicense(MountableFile.forClasspathResource("/myExtensionLicense.elic")) + ) { + hivemq.start(); + TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost()); + } + } + + @SuppressWarnings("CodeBlock2Expr") + public static class LicenceCheckerExtension implements ExtensionMain { + + @Override + public void extensionStart( + @NotNull ExtensionStartInput extensionStartInput, + @NotNull ExtensionStartOutput extensionStartOutput + ) { + final PublishInboundInterceptor publishInboundInterceptor = (publishInboundInput, publishInboundOutput) -> { + final File homeFolder = extensionStartInput.getServerInformation().getHomeFolder(); + final File myLicence = new File(homeFolder, "license/myLicense.lic"); + final File myExtensionLicence = new File(homeFolder, "license/myExtensionLicense.elic"); + + if (myLicence.exists() && myExtensionLicence.exists()) { + publishInboundOutput + .getPublishPacket() + .setPayload(ByteBuffer.wrap("modified".getBytes(StandardCharsets.UTF_8))); + } + }; + + final ClientInitializer clientInitializer = (initializerInput, clientContext) -> { + clientContext.addPublishInboundInterceptor(publishInboundInterceptor); + }; + + Services.initializerRegistry().setClientInitializer(clientInitializer); + } + + @Override + public void extensionStop( + @NotNull ExtensionStopInput extensionStopInput, + @NotNull ExtensionStopOutput extensionStopOutput + ) {} + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithoutPlatformExtensionsIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithoutPlatformExtensionsIT.java new file mode 100644 index 00000000000..f29897e6a1e --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/ContainerWithoutPlatformExtensionsIT.java @@ -0,0 +1,141 @@ +package org.testcontainers.hivemq; + +import com.hivemq.client.mqtt.MqttClient; +import com.hivemq.client.mqtt.MqttGlobalPublishFilter; +import com.hivemq.client.mqtt.mqtt5.Mqtt5BlockingClient; +import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish; +import com.hivemq.extension.sdk.api.ExtensionMain; +import com.hivemq.extension.sdk.api.auth.SimpleAuthenticator; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartOutput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopOutput; +import com.hivemq.extension.sdk.api.services.Services; +import com.hivemq.extension.sdk.api.services.builder.Builders; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContainerWithoutPlatformExtensionsIT { + + @NotNull + private final HiveMQExtension hiveMQExtension = HiveMQExtension + .builder() + .name("MyExtension") + .id("my-extension") + .version("1.0.0") + .mainClass(CheckerExtension.class) + .build(); + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void removeAllPlatformExtensions() throws InterruptedException { + try ( + final HiveMQContainer hivemq = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq4").withTag("4.7.4")) + .withExtension(hiveMQExtension) + .waitForExtension(hiveMQExtension) + .withoutPrepackagedExtensions() + ) { + hivemq.start(); + + final Mqtt5BlockingClient client = MqttClient + .builder() + .serverPort(hivemq.getMqttPort()) + .serverHost(hivemq.getHost()) + .useMqttVersion5() + .buildBlocking(); + + client.connect(); + final Mqtt5BlockingClient.Mqtt5Publishes publishes = client.publishes(MqttGlobalPublishFilter.ALL); + client.subscribeWith().topicFilter("extensions").send(); + + final Mqtt5Publish receive = publishes.receive(); + assertThat(receive.getPayload()).isPresent(); + final String extensionInfo = new String(receive.getPayloadAsBytes()); + + assertThat(extensionInfo).doesNotContain("hivemq-allow-all-extension"); + assertThat(extensionInfo).doesNotContain("hivemq-kafka-extension"); + assertThat(extensionInfo).doesNotContain("hivemq-bridge-extension"); + assertThat(extensionInfo).doesNotContain("hivemq-enterprise-security-extension"); + + hivemq.start(); + } + } + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void removeKafkaExtension() throws InterruptedException { + try ( + final HiveMQContainer hivemq = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq4").withTag("4.7.4")) + .withExtension(hiveMQExtension) + .waitForExtension(hiveMQExtension) + .withoutPrepackagedExtensions("hivemq-kafka-extension") + ) { + hivemq.start(); + + final Mqtt5BlockingClient client = MqttClient + .builder() + .serverPort(hivemq.getMqttPort()) + .serverHost(hivemq.getHost()) + .useMqttVersion5() + .buildBlocking(); + + client.connect(); + final Mqtt5BlockingClient.Mqtt5Publishes publishes = client.publishes(MqttGlobalPublishFilter.ALL); + client.subscribeWith().topicFilter("extensions").send(); + + final Mqtt5Publish receive = publishes.receive(); + assertThat(receive.getPayload().isPresent()).isTrue(); + final String extensionInfo = new String(receive.getPayloadAsBytes()); + + assertThat(extensionInfo).contains("hivemq-allow-all-extension"); + assertThat(extensionInfo).doesNotContain("hivemq-kafka-extension"); + assertThat(extensionInfo).contains("hivemq-bridge-extension"); + assertThat(extensionInfo).contains("hivemq-enterprise-security-extension"); + } + } + + public static class CheckerExtension implements ExtensionMain { + + @Override + public void extensionStart( + final @NotNull ExtensionStartInput extensionStartInput, + final @NotNull ExtensionStartOutput extensionStartOutput + ) { + final String extensionFolders = Arrays + .stream(extensionStartInput.getServerInformation().getExtensionsFolder().listFiles()) + .filter(File::isDirectory) + .map(File::getName) + .collect(Collectors.joining("\n")); + + final byte[] bytes = extensionFolders.getBytes(StandardCharsets.UTF_8); + Services + .publishService() + .publish(Builders.publish().topic("extensions").retain(true).payload(ByteBuffer.wrap(bytes)).build()); + + Services + .securityRegistry() + .setAuthenticatorProvider(authenticatorProviderInput -> { + return (SimpleAuthenticator) (simpleAuthInput, simpleAuthOutput) -> { + simpleAuthOutput.authenticateSuccessfully(); + }; + }); + } + + @Override + public void extensionStop( + final @NotNull ExtensionStopInput extensionStopInput, + final @NotNull ExtensionStopOutput extensionStopOutput + ) {} + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/CreateFileInCopiedDirectoryIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/CreateFileInCopiedDirectoryIT.java new file mode 100755 index 00000000000..df0f3ef3b9c --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/CreateFileInCopiedDirectoryIT.java @@ -0,0 +1,101 @@ +package org.testcontainers.hivemq; + +import com.hivemq.extension.sdk.api.ExtensionMain; +import com.hivemq.extension.sdk.api.interceptor.publish.PublishInboundInterceptor; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartOutput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopOutput; +import com.hivemq.extension.sdk.api.services.Services; +import com.hivemq.extension.sdk.api.services.intializer.ClientInitializer; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.hivemq.util.TestPublishModifiedUtil; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class CreateFileInCopiedDirectoryIT { + + private @NotNull MountableFile createDirectory() throws IOException { + final File directory = new File(Files.createTempDirectory("").toFile(), "directory"); + assertThat(directory.mkdir()).isTrue(); + final File subdirectory = new File(directory, "sub-directory"); + assertThat(subdirectory.mkdir()).isTrue(); + return MountableFile.forHostPath(directory.getPath()); + } + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test() throws Exception { + final HiveMQExtension extension = HiveMQExtension + .builder() + .id("extension-1") + .name("my-extension") + .version("1.0") + .mainClass(FileCreatorExtension.class) + .build(); + + try ( + final HiveMQContainer hivemq = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq-ce").withTag("2024.3") + ) + .withHiveMQConfig(MountableFile.forClasspathResource("/inMemoryConfig.xml")) + .withExtension(extension) + .waitForExtension(extension) + .withFileInHomeFolder(createDirectory(), "directory") + ) { + hivemq.start(); + TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost()); + } + } + + public static class FileCreatorExtension implements ExtensionMain { + + @Override + public void extensionStart( + @NotNull ExtensionStartInput extensionStartInput, + @NotNull ExtensionStartOutput extensionStartOutput + ) { + final PublishInboundInterceptor publishInboundInterceptor = (publishInboundInput, publishInboundOutput) -> { + final File homeFolder = extensionStartInput.getServerInformation().getHomeFolder(); + + final File dir = new File(homeFolder, "directory"); + final File dirFile = new File(dir, "file.txt"); + final File subDir = new File(dir, "sub-directory"); + final File subDirFile = new File(subDir, "file.txt"); + + try { + if (dirFile.createNewFile() && subDirFile.createNewFile()) { + publishInboundOutput + .getPublishPacket() + .setPayload(ByteBuffer.wrap("modified".getBytes(StandardCharsets.UTF_8))); + } + } catch (IOException e) { + e.printStackTrace(); + } + }; + + final ClientInitializer clientInitializer = (initializerInput, clientContext) -> { + clientContext.addPublishInboundInterceptor(publishInboundInterceptor); + }; + + Services.initializerRegistry().setClientInitializer(clientInitializer); + } + + @Override + public void extensionStop( + @NotNull ExtensionStopInput extensionStopInput, + @NotNull ExtensionStopOutput extensionStopOutput + ) {} + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/CreateFileInExtensionDirectoryIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/CreateFileInExtensionDirectoryIT.java new file mode 100755 index 00000000000..6f65ebb1e9f --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/CreateFileInExtensionDirectoryIT.java @@ -0,0 +1,92 @@ +package org.testcontainers.hivemq; + +import com.hivemq.extension.sdk.api.ExtensionMain; +import com.hivemq.extension.sdk.api.interceptor.publish.PublishInboundInterceptor; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartOutput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopOutput; +import com.hivemq.extension.sdk.api.services.Services; +import com.hivemq.extension.sdk.api.services.intializer.ClientInitializer; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.testcontainers.hivemq.util.TestPublishModifiedUtil; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +class CreateFileInExtensionDirectoryIT { + + @ParameterizedTest + @ValueSource( + strings = { + "2020.1", // first version that provided a container image + "2024.3", // version that runs the image as a non-root user by default + } + ) + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test(final @NotNull String hivemqCeTag) throws Exception { + final HiveMQExtension hiveMQExtension = HiveMQExtension + .builder() + .id("extension-1") + .name("my-extension") + .version("1.0") + .mainClass(FileCreatorExtension.class) + .build(); + + try ( + final HiveMQContainer hivemq = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq-ce").withTag(hivemqCeTag) + ) + .withHiveMQConfig(MountableFile.forClasspathResource("/inMemoryConfig.xml")) + .waitForExtension(hiveMQExtension) + .withExtension(hiveMQExtension) + ) { + hivemq.start(); + TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost()); + } + } + + public static class FileCreatorExtension implements ExtensionMain { + + @Override + public void extensionStart( + @NotNull ExtensionStartInput extensionStartInput, + @NotNull ExtensionStartOutput extensionStartOutput + ) { + final PublishInboundInterceptor publishInboundInterceptor = (publishInboundInput, publishInboundOutput) -> { + final File extensionHomeFolder = extensionStartInput.getExtensionInformation().getExtensionHomeFolder(); + + final File file = new File(extensionHomeFolder, "myfile.txt"); + try { + if (file.createNewFile()) { + publishInboundOutput + .getPublishPacket() + .setPayload(ByteBuffer.wrap("modified".getBytes(StandardCharsets.UTF_8))); + } + } catch (IOException e) { + e.printStackTrace(); + } + }; + + final ClientInitializer clientInitializer = (initializerInput, clientContext) -> { + clientContext.addPublishInboundInterceptor(publishInboundInterceptor); + }; + + Services.initializerRegistry().setClientInitializer(clientInitializer); + } + + @Override + public void extensionStop( + @NotNull ExtensionStopInput extensionStopInput, + @NotNull ExtensionStopOutput extensionStopOutput + ) {} + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/DisableEnableExtensionFromDirectoryIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/DisableEnableExtensionFromDirectoryIT.java new file mode 100644 index 00000000000..b632bb45328 --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/DisableEnableExtensionFromDirectoryIT.java @@ -0,0 +1,36 @@ +package org.testcontainers.hivemq; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.slf4j.event.Level; +import org.testcontainers.hivemq.util.TestPublishModifiedUtil; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class DisableEnableExtensionFromDirectoryIT { + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test() throws Exception { + try ( + final HiveMQContainer hivemq = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq4").withTag("4.7.4")) + .withExtension(MountableFile.forClasspathResource("/modifier-extension")) + .waitForExtension("Modifier Extension") + .withLogLevel(Level.DEBUG) + ) { + hivemq.start(); + + TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost()); + hivemq.disableExtension("Modifier Extension", "modifier-extension"); + assertThatExceptionOfType(ExecutionException.class) + .isThrownBy(() -> TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost())); + hivemq.enableExtension("Modifier Extension", "modifier-extension"); + TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost()); + } + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/DisableEnableExtensionIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/DisableEnableExtensionIT.java new file mode 100644 index 00000000000..189f34308a1 --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/DisableEnableExtensionIT.java @@ -0,0 +1,49 @@ +package org.testcontainers.hivemq; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.slf4j.event.Level; +import org.testcontainers.hivemq.util.MyExtension; +import org.testcontainers.hivemq.util.TestPublishModifiedUtil; +import org.testcontainers.utility.DockerImageName; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class DisableEnableExtensionIT { + + @NotNull + private final HiveMQExtension hiveMQExtension = HiveMQExtension + .builder() + .id("extension-1") + .name("my-extension") + .version("1.0") + .disabledOnStartup(true) + .mainClass(MyExtension.class) + .build(); + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test() throws Exception { + try ( + final HiveMQContainer hivemq = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq4").withTag("4.7.4")) + .withExtension(hiveMQExtension) + .withLogLevel(Level.DEBUG) + ) { + hivemq.start(); + + assertThatExceptionOfType(ExecutionException.class) + .isThrownBy(() -> TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost())); + hivemq.enableExtension(hiveMQExtension); + TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost()); + hivemq.disableExtension(hiveMQExtension); + assertThatExceptionOfType(ExecutionException.class) + .isThrownBy(() -> TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost())); + hivemq.enableExtension(hiveMQExtension); + TestPublishModifiedUtil.testPublishModified(hivemq.getMqttPort(), hivemq.getHost()); + } + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/HiveMQExtensionTest.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/HiveMQExtensionTest.java new file mode 100644 index 00000000000..38b8e918925 --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/HiveMQExtensionTest.java @@ -0,0 +1,14 @@ +package org.testcontainers.hivemq; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class HiveMQExtensionTest { + + @Test + void builder_classDoesNotImplementExtensionMain_exception() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> HiveMQExtension.builder().mainClass(HiveMQExtensionTest.class)); + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/HiveMQTestContainerCore.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/HiveMQTestContainerCore.java new file mode 100644 index 00000000000..ede972e957d --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/HiveMQTestContainerCore.java @@ -0,0 +1,95 @@ +package org.testcontainers.hivemq; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.File; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class HiveMQTestContainerCore { + + @NotNull + final HiveMQContainer container = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq-ce").withTag("2024.3")); + + @TempDir + File tempDir; + + @Test + void withExtension_fileDoesNotExist_Exception() { + final MountableFile mountableFile = MountableFile.forHostPath("/this/does/not/exist"); + assertThatExceptionOfType(ContainerLaunchException.class) + .isThrownBy(() -> container.withExtension(mountableFile)); + } + + @Test + void withExtension_fileNoDirectory_Exception() throws IOException { + final File extension = new File(tempDir, "extension"); + assertThat(extension.createNewFile()).isTrue(); + final MountableFile mountableFile = MountableFile.forHostPath(extension.getAbsolutePath()); + assertThatExceptionOfType(ContainerLaunchException.class) + .isThrownBy(() -> container.withExtension(mountableFile)); + } + + @Test + void withLicense_fileDoesNotExist_Exception() { + final MountableFile mountableFile = MountableFile.forHostPath("/this/does/not/exist"); + assertThatExceptionOfType(ContainerLaunchException.class) + .isThrownBy(() -> container.withLicense(mountableFile)); + } + + @Test + void withExtension_fileEndingWrong_Exception() throws IOException { + final File extension = new File(tempDir, "extension.wrong"); + assertThat(extension.createNewFile()).isTrue(); + final MountableFile mountableFile = MountableFile.forHostPath(extension.getAbsolutePath()); + assertThatExceptionOfType(ContainerLaunchException.class) + .isThrownBy(() -> container.withLicense(mountableFile)); + } + + @Test + void withHiveMQConfig_fileDoesNotExist_Exception() { + final MountableFile mountableFile = MountableFile.forHostPath("/this/does/not/exist"); + assertThatExceptionOfType(ContainerLaunchException.class) + .isThrownBy(() -> container.withHiveMQConfig(mountableFile)); + } + + @Test + void withFileInHomeFolder_fileDoesNotExist_Exception() { + final MountableFile mountableFile = MountableFile.forHostPath("/this/does/not/exist"); + assertThatExceptionOfType(ContainerLaunchException.class) + .isThrownBy(() -> container.withFileInHomeFolder(mountableFile, "some/path")); + } + + @Test + void withFileInHomeFolder_pathEmpty_Exception() { + final MountableFile mountableFile = MountableFile.forHostPath("/this/does/not/exist"); + assertThatExceptionOfType(ContainerLaunchException.class) + .isThrownBy(() -> container.withFileInHomeFolder(mountableFile, "")); + } + + @Test + void withFileInExtensionHomeFolder_withPath_fileDoesNotExist_Exception() { + final MountableFile mountableFile = MountableFile.forHostPath("/this/does/not/exist"); + assertThatExceptionOfType(ContainerLaunchException.class) + .isThrownBy(() -> container.withFileInExtensionHomeFolder(mountableFile, "my-extension", "some/path")); + } + + @Test + void withFileInExtensionHomeFolder_fileDoesNotExist_Exception() { + final MountableFile mountableFile = MountableFile.forHostPath("/this/does/not/exist"); + assertThatExceptionOfType(ContainerLaunchException.class) + .isThrownBy(() -> { + final HiveMQContainer hiveMQContainer = container.withFileInExtensionHomeFolder( + mountableFile, + "my-extension" + ); + }); + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/PathUtilTest.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/PathUtilTest.java new file mode 100755 index 00000000000..d845acb6684 --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/PathUtilTest.java @@ -0,0 +1,43 @@ +package org.testcontainers.hivemq; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PathUtilTest { + + @Test + void prepareInnerPath_emptyString() { + assertThat(PathUtil.prepareInnerPath("")).isEqualTo("/"); + } + + @Test + void prepareInnerPath_onlyDelimiter() { + assertThat(PathUtil.prepareInnerPath("/")).isEqualTo("/"); + } + + @Test + void prepareInnerPath_noDelimiter() { + assertThat(PathUtil.prepareInnerPath("path")).isEqualTo("/path/"); + } + + @Test + void prepareInnerPath_delimiterAtEnd() { + assertThat(PathUtil.prepareInnerPath("path/")).isEqualTo("/path/"); + } + + @Test + void prepareInnerPath_delimiterAtBeginning() { + assertThat(PathUtil.prepareInnerPath("/path")).isEqualTo("/path/"); + } + + @Test + void prepareAppendPath_delimiterAtBeginning_noChange() { + assertThat(PathUtil.prepareAppendPath("/path")).isEqualTo("/path"); + } + + @Test + void prepareAppendPath_delimiterAtBeginning_delimiterAdded() { + assertThat(PathUtil.prepareAppendPath("path")).isEqualTo("/path"); + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoDisableExtensionsIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoDisableExtensionsIT.java new file mode 100644 index 00000000000..b2ac3acbbcb --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoDisableExtensionsIT.java @@ -0,0 +1,74 @@ +package org.testcontainers.hivemq.docs; + +import org.junit.jupiter.api.Test; +import org.testcontainers.hivemq.HiveMQContainer; +import org.testcontainers.hivemq.HiveMQExtension; +import org.testcontainers.hivemq.util.MyExtension; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +@Testcontainers +class DemoDisableExtensionsIT { + + // noExtensions { + @Container + final HiveMQContainer hivemqNoExtensions = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq4").withTag("4.7.4") + ) + .withoutPrepackagedExtensions(); + + // } + + // noKafkaExtension { + @Container + final HiveMQContainer hivemqNoKafkaExtension = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq4").withTag("4.7.4") + ) + .withoutPrepackagedExtensions("hivemq-kafka-extension"); + + // } + + // startDisabled { + private final HiveMQExtension hiveMQExtension = HiveMQExtension + .builder() + .id("extension-1") + .name("my-extension") + .version("1.0") + .disabledOnStartup(true) + .mainClass(MyExtension.class) + .build(); + + @Container + final HiveMQContainer hivemq = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq4").withTag("4.7.4")) + .withExtension(hiveMQExtension); + + // } + + // startFromFilesystem { + @Container + final HiveMQContainer hivemqExtensionFromFilesystem = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq4").withTag("4.7.4") + ) + .withExtension(MountableFile.forHostPath("src/test/resources/modifier-extension")); + + // } + + // hiveRuntimeEnable { + @Test + void test_disable_enable_extension() throws Exception { + hivemq.enableExtension(hiveMQExtension); + hivemq.disableExtension(hiveMQExtension); + } + + // } + + // runtimeEnableFilesystem { + @Test + void test_disable_enable_extension_from_filesystem() throws Exception { + hivemqExtensionFromFilesystem.disableExtension("Modifier Extension", "modifier-extension"); + hivemqExtensionFromFilesystem.enableExtension("Modifier Extension", "modifier-extension"); + } + // } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoExtensionTestsIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoExtensionTestsIT.java new file mode 100644 index 00000000000..a1ff664649d --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoExtensionTestsIT.java @@ -0,0 +1,64 @@ +package org.testcontainers.hivemq.docs; + +import com.hivemq.client.mqtt.mqtt5.Mqtt5BlockingClient; +import com.hivemq.client.mqtt.mqtt5.Mqtt5Client; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.hivemq.HiveMQContainer; +import org.testcontainers.hivemq.HiveMQExtension; +import org.testcontainers.hivemq.util.MyExtensionWithSubclasses; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.util.concurrent.TimeUnit; + +@Testcontainers +class DemoExtensionTestsIT { + + // waitStrategy { + @Container + final HiveMQContainer hivemqWithWaitStrategy = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq4").withTag("4.7.4") + ) + .withExtension(MountableFile.forClasspathResource("/modifier-extension")) + .waitForExtension("Modifier Extension"); + + // } + + // extensionClasspath { + final HiveMQExtension hiveMQEClasspathxtension = HiveMQExtension + .builder() + .id("extension-1") + .name("my-extension") + .version("1.0") + .mainClass(MyExtensionWithSubclasses.class) + .build(); + + @Container + final HiveMQContainer hivemqWithClasspathExtension = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq-ce").withTag("2024.3") + ) + .waitForExtension(hiveMQEClasspathxtension) + .withExtension(hiveMQEClasspathxtension) + .withHiveMQConfig(MountableFile.forClasspathResource("/inMemoryConfig.xml")); + + // } + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test() throws Exception { + // mqtt5client { + final Mqtt5BlockingClient client = Mqtt5Client + .builder() + .serverPort(hivemqWithClasspathExtension.getMqttPort()) + .serverHost(hivemqWithClasspathExtension.getHost()) + .buildBlocking(); + + client.connect(); + client.disconnect(); + // } + + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoFilesIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoFilesIT.java new file mode 100644 index 00000000000..8fe08b24750 --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoFilesIT.java @@ -0,0 +1,76 @@ +package org.testcontainers.hivemq.docs; + +import com.hivemq.client.mqtt.mqtt5.Mqtt5BlockingClient; +import com.hivemq.client.mqtt.mqtt5.Mqtt5Client; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.hivemq.HiveMQContainer; +import org.testcontainers.hivemq.HiveMQExtension; +import org.testcontainers.hivemq.util.MyExtension; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.util.concurrent.TimeUnit; + +@Testcontainers +class DemoFilesIT { + + // hivemqHome { + final HiveMQContainer hivemqFileInHome = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq-ce").withTag("2024.3") + ) + .withFileInHomeFolder( + MountableFile.forHostPath("src/test/resources/additionalFile.txt"), + "/path/in/home/folder" + ); + + // } + + // extensionHome { + @Container + final HiveMQContainer hivemqFileInExtensionHome = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq-ce").withTag("2024.3") + ) + .withExtension( + HiveMQExtension + .builder() + .id("extension-1") + .name("my-extension") + .version("1.0") + .mainClass(MyExtension.class) + .build() + ) + .withFileInExtensionHomeFolder( + MountableFile.forHostPath("src/test/resources/additionalFile.txt"), + "extension-1", + "/path/in/extension/home" + ); + + // } + + // withLicenses { + @Container + final HiveMQContainer hivemq = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq-ce").withTag("2024.3")) + .withLicense(MountableFile.forHostPath("src/test/resources/myLicense.lic")) + .withLicense(MountableFile.forHostPath("src/test/resources/myExtensionLicense.elic")); + + // } + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test() throws Exception { + // mqtt5client { + final Mqtt5BlockingClient client = Mqtt5Client + .builder() + .serverPort(hivemq.getMqttPort()) + .serverHost(hivemq.getHost()) + .buildBlocking(); + + client.connect(); + client.disconnect(); + // } + + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoHiveMQContainerIT.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoHiveMQContainerIT.java new file mode 100644 index 00000000000..ed063617537 --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/docs/DemoHiveMQContainerIT.java @@ -0,0 +1,65 @@ +package org.testcontainers.hivemq.docs; + +import com.hivemq.client.mqtt.mqtt5.Mqtt5BlockingClient; +import com.hivemq.client.mqtt.mqtt5.Mqtt5Client; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.slf4j.event.Level; +import org.testcontainers.hivemq.HiveMQContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.util.concurrent.TimeUnit; + +@Testcontainers +class DemoHiveMQContainerIT { + + // ceVersion { + @Container + final HiveMQContainer hivemqCe = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq-ce").withTag("2024.3")) + .withLogLevel(Level.DEBUG); + + // } + + // hiveEEVersion { + @Container + final HiveMQContainer hivemqEe = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq4").withTag("4.7.4")) + .withLogLevel(Level.DEBUG); + + // } + + // eeVersionWithControlCenter { + @Container + final HiveMQContainer hivemqEeWithControlCenter = new HiveMQContainer( + DockerImageName.parse("hivemq/hivemq4").withTag("4.7.4") + ) + .withLogLevel(Level.DEBUG) + .withHiveMQConfig(MountableFile.forClasspathResource("/inMemoryConfig.xml")) + .withControlCenter(); + + // } + + // specificVersion { + @Container + final HiveMQContainer hivemqSpecificVersion = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq-ce:2024.3")); + + // } + + @Test + @Timeout(value = 3, unit = TimeUnit.MINUTES) + void test() throws Exception { + // mqtt5client { + final Mqtt5BlockingClient client = Mqtt5Client + .builder() + .serverPort(hivemqCe.getMqttPort()) + .serverHost(hivemqCe.getHost()) + .buildBlocking(); + + client.connect(); + client.disconnect(); + // } + + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/util/MyExtension.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/util/MyExtension.java new file mode 100755 index 00000000000..4c3991122d7 --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/util/MyExtension.java @@ -0,0 +1,42 @@ +package org.testcontainers.hivemq.util; + +import com.hivemq.extension.sdk.api.ExtensionMain; +import com.hivemq.extension.sdk.api.interceptor.publish.PublishInboundInterceptor; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartOutput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopOutput; +import com.hivemq.extension.sdk.api.services.Services; +import com.hivemq.extension.sdk.api.services.intializer.ClientInitializer; +import org.jetbrains.annotations.NotNull; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +@SuppressWarnings("CodeBlock2Expr") +public class MyExtension implements ExtensionMain { + + @Override + public void extensionStart( + final @NotNull ExtensionStartInput extensionStartInput, + final @NotNull ExtensionStartOutput extensionStartOutput + ) { + final PublishInboundInterceptor publishInboundInterceptor = (publishInboundInput, publishInboundOutput) -> { + publishInboundOutput + .getPublishPacket() + .setPayload(ByteBuffer.wrap("modified".getBytes(StandardCharsets.UTF_8))); + }; + + final ClientInitializer clientInitializer = (initializerInput, clientContext) -> { + clientContext.addPublishInboundInterceptor(publishInboundInterceptor); + }; + + Services.initializerRegistry().setClientInitializer(clientInitializer); + } + + @Override + public void extensionStop( + final @NotNull ExtensionStopInput extensionStopInput, + final @NotNull ExtensionStopOutput extensionStopOutput + ) {} +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/util/MyExtensionWithSubclasses.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/util/MyExtensionWithSubclasses.java new file mode 100755 index 00000000000..9e4565b15c9 --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/util/MyExtensionWithSubclasses.java @@ -0,0 +1,31 @@ +package org.testcontainers.hivemq.util; + +import com.hivemq.extension.sdk.api.ExtensionMain; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStartOutput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopInput; +import com.hivemq.extension.sdk.api.parameter.ExtensionStopOutput; +import com.hivemq.extension.sdk.api.services.Services; +import com.hivemq.extension.sdk.api.services.intializer.ClientInitializer; +import org.jetbrains.annotations.NotNull; + +public class MyExtensionWithSubclasses implements ExtensionMain { + + @Override + public void extensionStart( + final @NotNull ExtensionStartInput extensionStartInput, + final @NotNull ExtensionStartOutput extensionStartOutput + ) { + final ClientInitializer clientInitializer = (initializerInput, clientContext) -> { + clientContext.addPublishInboundInterceptor(new PublishModifier()); + }; + + Services.initializerRegistry().setClientInitializer(clientInitializer); + } + + @Override + public void extensionStop( + final @NotNull ExtensionStopInput extensionStopInput, + final @NotNull ExtensionStopOutput extensionStopOutput + ) {} +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/util/PublishModifier.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/util/PublishModifier.java new file mode 100755 index 00000000000..ec5b63ee1e6 --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/util/PublishModifier.java @@ -0,0 +1,22 @@ +package org.testcontainers.hivemq.util; + +import com.hivemq.extension.sdk.api.interceptor.publish.PublishInboundInterceptor; +import com.hivemq.extension.sdk.api.interceptor.publish.parameter.PublishInboundInput; +import com.hivemq.extension.sdk.api.interceptor.publish.parameter.PublishInboundOutput; +import org.jetbrains.annotations.NotNull; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +public class PublishModifier implements PublishInboundInterceptor { + + @Override + public void onInboundPublish( + final @NotNull PublishInboundInput publishInboundInput, + final @NotNull PublishInboundOutput publishInboundOutput + ) { + publishInboundOutput + .getPublishPacket() + .setPayload(ByteBuffer.wrap("modified".getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/modules/hivemq/src/test/java/org/testcontainers/hivemq/util/TestPublishModifiedUtil.java b/modules/hivemq/src/test/java/org/testcontainers/hivemq/util/TestPublishModifiedUtil.java new file mode 100755 index 00000000000..963570c07c4 --- /dev/null +++ b/modules/hivemq/src/test/java/org/testcontainers/hivemq/util/TestPublishModifiedUtil.java @@ -0,0 +1,59 @@ +package org.testcontainers.hivemq.util; + +import com.hivemq.client.mqtt.MqttGlobalPublishFilter; +import com.hivemq.client.mqtt.mqtt5.Mqtt5BlockingClient; +import com.hivemq.client.mqtt.mqtt5.Mqtt5Client; +import org.jetbrains.annotations.NotNull; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.CompletableFuture; + +public class TestPublishModifiedUtil { + + public static void testPublishModified(final int mqttPort, final @NotNull String host) throws Exception { + final CompletableFuture publishReceived = new CompletableFuture<>(); + + final Mqtt5BlockingClient publisher = Mqtt5Client + .builder() + .serverPort(mqttPort) + .serverHost(host) + .identifier("publisher") + .buildBlocking(); + publisher.connect(); + + final Mqtt5BlockingClient subscriber = Mqtt5Client + .builder() + .serverPort(mqttPort) + .serverHost(host) + .identifier("subscriber") + .buildBlocking(); + subscriber.connect(); + subscriber.subscribeWith().topicFilter("test/topic").send(); + subscriber + .toAsync() + .publishes( + MqttGlobalPublishFilter.ALL, + publish -> { + if (Arrays.equals(publish.getPayloadAsBytes(), "modified".getBytes(StandardCharsets.UTF_8))) { + publishReceived.complete(null); + } else { + publishReceived.completeExceptionally( + new IllegalArgumentException( + "unexpected payload: " + new String(publish.getPayloadAsBytes()) + ) + ); + } + } + ); + + publisher.publishWith().topic("test/topic").payload("unmodified".getBytes(StandardCharsets.UTF_8)).send(); + + try { + publishReceived.get(); + } finally { + publisher.disconnect(); + subscriber.disconnect(); + } + } +} diff --git a/modules/hivemq/src/test/resources/additionalFile.txt b/modules/hivemq/src/test/resources/additionalFile.txt new file mode 100755 index 00000000000..e69de29bb2d diff --git a/modules/hivemq/src/test/resources/config.xml b/modules/hivemq/src/test/resources/config.xml new file mode 100755 index 00000000000..128015e2fee --- /dev/null +++ b/modules/hivemq/src/test/resources/config.xml @@ -0,0 +1,15 @@ + + + + + + 1883 + 0.0.0.0 + + + + + 0 + + + diff --git a/modules/hivemq/src/test/resources/inMemoryConfig.xml b/modules/hivemq/src/test/resources/inMemoryConfig.xml new file mode 100644 index 00000000000..33373359243 --- /dev/null +++ b/modules/hivemq/src/test/resources/inMemoryConfig.xml @@ -0,0 +1,6 @@ + + + + in-memory + + diff --git a/modules/hivemq/src/test/resources/logback-test.xml b/modules/hivemq/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..8a33fc398f0 --- /dev/null +++ b/modules/hivemq/src/test/resources/logback-test.xml @@ -0,0 +1,18 @@ + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + + + + + diff --git a/modules/hivemq/src/test/resources/modifier-extension-wrong-name/hivemq-extension.xml b/modules/hivemq/src/test/resources/modifier-extension-wrong-name/hivemq-extension.xml new file mode 100755 index 00000000000..6ca9c899df4 --- /dev/null +++ b/modules/hivemq/src/test/resources/modifier-extension-wrong-name/hivemq-extension.xml @@ -0,0 +1,8 @@ + + + modifier-extension + 1.0-SNAPSHOT + Modifier Extension + HiveMQ GmbH + 1000 + diff --git a/modules/hivemq/src/test/resources/modifier-extension-wrong-name/modifier-extension-1.0-SNAPSHOT.jar b/modules/hivemq/src/test/resources/modifier-extension-wrong-name/modifier-extension-1.0-SNAPSHOT.jar new file mode 100644 index 00000000000..18a7ea85f53 Binary files /dev/null and b/modules/hivemq/src/test/resources/modifier-extension-wrong-name/modifier-extension-1.0-SNAPSHOT.jar differ diff --git a/modules/hivemq/src/test/resources/modifier-extension/hivemq-extension.xml b/modules/hivemq/src/test/resources/modifier-extension/hivemq-extension.xml new file mode 100755 index 00000000000..6ca9c899df4 --- /dev/null +++ b/modules/hivemq/src/test/resources/modifier-extension/hivemq-extension.xml @@ -0,0 +1,8 @@ + + + modifier-extension + 1.0-SNAPSHOT + Modifier Extension + HiveMQ GmbH + 1000 + diff --git a/modules/hivemq/src/test/resources/modifier-extension/modifier-extension-1.0-SNAPSHOT.jar b/modules/hivemq/src/test/resources/modifier-extension/modifier-extension-1.0-SNAPSHOT.jar new file mode 100644 index 00000000000..18a7ea85f53 Binary files /dev/null and b/modules/hivemq/src/test/resources/modifier-extension/modifier-extension-1.0-SNAPSHOT.jar differ diff --git a/modules/hivemq/src/test/resources/myExtensionLicense.elic b/modules/hivemq/src/test/resources/myExtensionLicense.elic new file mode 100755 index 00000000000..e69de29bb2d diff --git a/modules/hivemq/src/test/resources/myLicense.lic b/modules/hivemq/src/test/resources/myLicense.lic new file mode 100755 index 00000000000..e69de29bb2d diff --git a/modules/influxdb/build.gradle b/modules/influxdb/build.gradle new file mode 100644 index 00000000000..145aa4aac52 --- /dev/null +++ b/modules/influxdb/build.gradle @@ -0,0 +1,10 @@ +description = "Testcontainers :: InfluxDB" + +dependencies { + api project(':testcontainers') + + compileOnly 'org.influxdb:influxdb-java:2.25' + + testImplementation 'org.influxdb:influxdb-java:2.25' + testImplementation "com.influxdb:influxdb-client-java:7.5.0" +} diff --git a/modules/influxdb/src/main/java/org/testcontainers/containers/InfluxDBContainer.java b/modules/influxdb/src/main/java/org/testcontainers/containers/InfluxDBContainer.java new file mode 100644 index 00000000000..8e908e8e229 --- /dev/null +++ b/modules/influxdb/src/main/java/org/testcontainers/containers/InfluxDBContainer.java @@ -0,0 +1,279 @@ +package org.testcontainers.containers; + +import lombok.Getter; +import org.influxdb.InfluxDB; +import org.influxdb.InfluxDBFactory; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; + +import java.util.Collections; +import java.util.Optional; +import java.util.Set; + +/** + * Testcontainers implementation for InfluxDB. + *

+ * Supported image: {@code influxdb} + *

+ * Exposed ports: 8086 + */ +public class InfluxDBContainer> extends GenericContainer { + + public static final Integer INFLUXDB_PORT = 8086; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("influxdb"); + + private static final String DEFAULT_TAG = "1.4.3"; + + @Deprecated + public static final String VERSION = DEFAULT_TAG; + + private static final int NO_CONTENT_STATUS_CODE = 204; + + @Getter + private String username = "test-user"; + + @Getter + private String password = "test-password"; + + /** + * Properties of InfluxDB 1.x + */ + private boolean authEnabled = true; + + private String admin = "admin"; + + private String adminPassword = "password"; + + @Getter + private String database; + + /** + * Properties of InfluxDB 2.x + */ + @Getter + private String bucket = "test-bucket"; + + @Getter + private String organization = "test-org"; + + @Getter + private Optional retention = Optional.empty(); + + @Getter + private Optional adminToken = Optional.empty(); + + private final boolean isAtLeastMajorVersion2; + + /** + * @deprecated use {@link #InfluxDBContainer(DockerImageName)} instead + */ + @Deprecated + public InfluxDBContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + /** + * @deprecated use {@link #InfluxDBContainer(DockerImageName)} instead + */ + @Deprecated + public InfluxDBContainer(final String version) { + this(DEFAULT_IMAGE_NAME.withTag(version)); + } + + public InfluxDBContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + this.waitStrategy = + new HttpWaitStrategy() + .forPath("/ping") + .withBasicCredentials(this.username, this.password) + .forStatusCode(NO_CONTENT_STATUS_CODE); + + this.isAtLeastMajorVersion2 = + new ComparableVersion(dockerImageName.getVersionPart()).isGreaterThanOrEqualTo("2.0.0"); + addExposedPort(INFLUXDB_PORT); + } + + /** + * Sets the InfluxDB environment variables based on the version + */ + @Override + protected void configure() { + if (this.isAtLeastMajorVersion2) { + configureInfluxDBV2(); + } else { + configureInfluxDBV1(); + } + } + + /** + * Sets the InfluxDB 2.x environment variables + * + * @see InfluxDB Dockerhub for full documentation on InfluxDB's + * envrinoment variables + */ + private void configureInfluxDBV2() { + addEnv("DOCKER_INFLUXDB_INIT_MODE", "setup"); + + addEnv("DOCKER_INFLUXDB_INIT_USERNAME", this.username); + addEnv("DOCKER_INFLUXDB_INIT_PASSWORD", this.password); + + addEnv("DOCKER_INFLUXDB_INIT_ORG", this.organization); + addEnv("DOCKER_INFLUXDB_INIT_BUCKET", this.bucket); + + this.retention.ifPresent(ret -> addEnv("DOCKER_INFLUXDB_INIT_RETENTION", ret)); + this.adminToken.ifPresent(token -> addEnv("DOCKER_INFLUXDB_INIT_ADMIN_TOKEN", token)); + } + + /** + * Sets the InfluxDB 1.x environment variables + */ + private void configureInfluxDBV1() { + addEnv("INFLUXDB_USER", this.username); + addEnv("INFLUXDB_USER_PASSWORD", this.password); + + addEnv("INFLUXDB_HTTP_AUTH_ENABLED", String.valueOf(this.authEnabled)); + + addEnv("INFLUXDB_ADMIN_USER", this.admin); + addEnv("INFLUXDB_ADMIN_PASSWORD", this.adminPassword); + + addEnv("INFLUXDB_DB", this.database); + } + + @Override + public Set getLivenessCheckPortNumbers() { + return Collections.singleton(getMappedPort(INFLUXDB_PORT)); + } + + /** + * Set user for InfluxDB + * + * @param username The username to set for the system's initial super-user + * @return a reference to this container instance + */ + public InfluxDBContainer withUsername(final String username) { + this.username = username; + return this; + } + + /** + * Set password for InfluxDB + * + * @param password The password to set for the system's initial super-user + * @return a reference to this container instance + */ + public InfluxDBContainer withPassword(final String password) { + this.password = password; + return this; + } + + /** + * Determines if authentication should be enabled or not + * + * @param authEnabled Enables authentication. + * @return a reference to this container instance + */ + public InfluxDBContainer withAuthEnabled(final boolean authEnabled) { + this.authEnabled = authEnabled; + return this.self(); + } + + /** + * Sets the admin user + * + * @param admin The name of the admin user to be created. If this is unset, no admin user is created. + * @return a reference to this container instance + */ + public InfluxDBContainer withAdmin(final String admin) { + this.admin = admin; + return this.self(); + } + + /** + * Sets the admin password + * + * @param adminPassword The password for the admin user. If this is unset, a random password is generated and + * printed to standard out. + * @return a reference to this container instance + */ + public InfluxDBContainer withAdminPassword(final String adminPassword) { + this.adminPassword = adminPassword; + return this.self(); + } + + /** + * Initializes database with given name + * + * @param database name of the database. + * @return a reference to this container instance + */ + public InfluxDBContainer withDatabase(final String database) { + this.database = database; + return this.self(); + } + + /** + * Sets the organization name + * + * @param organization The organization for the initial setup of influxDB. + * @return a reference to this container instance + */ + public InfluxDBContainer withOrganization(final String organization) { + this.organization = organization; + return this; + } + + /** + * Initializes bucket with given name + * + * @param bucket name of the bucket. + * @return a reference to this container instance + */ + public InfluxDBContainer withBucket(final String bucket) { + this.bucket = bucket; + return this; + } + + /** + * Sets the retention in days + * + * @param retention days bucket will retain data (0 is infinite, default is 0). + * @return a reference to this container instance + */ + public InfluxDBContainer withRetention(final String retention) { + this.retention = Optional.of(retention); + return this; + } + + /** + * Sets the admin token + * + * @param adminToken Authentication token to associate with the admin user. + * @return a reference to this container instance + */ + public InfluxDBContainer withAdminToken(final String adminToken) { + this.adminToken = Optional.of(adminToken); + return this; + } + + /** + * @return a url to InfluxDB + */ + public String getUrl() { + return "http://" + getHost() + ":" + getMappedPort(INFLUXDB_PORT); + } + + /** + * @return a InfluxDB client for InfluxDB 1.x. + * @deprecated Use the new InfluxDB client library. + */ + @Deprecated + public InfluxDB getNewInfluxDB() { + final InfluxDB influxDB = InfluxDBFactory.connect(getUrl(), this.username, this.password); + influxDB.setDatabase(this.database); + return influxDB; + } +} diff --git a/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerTest.java b/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerTest.java new file mode 100644 index 00000000000..53678fa6dce --- /dev/null +++ b/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerTest.java @@ -0,0 +1,166 @@ +package org.testcontainers.containers; + +import com.influxdb.client.InfluxDBClient; +import com.influxdb.client.InfluxDBClientFactory; +import com.influxdb.client.InfluxDBClientOptions; +import com.influxdb.client.QueryApi; +import com.influxdb.client.WriteApi; +import com.influxdb.client.domain.Bucket; +import com.influxdb.client.domain.BucketRetentionRules; +import com.influxdb.client.domain.WritePrecision; +import com.influxdb.client.write.Point; +import com.influxdb.query.FluxRecord; +import com.influxdb.query.FluxTable; +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.DockerImageName; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class InfluxDBContainerTest { + + private static final String USERNAME = "new-test-user"; + + private static final String PASSWORD = "new-test-password"; + + private static final String ORG = "new-test-org"; + + private static final String BUCKET = "new-test-bucket"; + + private static final String RETENTION = "1w"; + + private static final String ADMIN_TOKEN = "super-secret-token"; + + private static final int SECONDS_IN_WEEK = 604800; + + @Test + void getInfluxDBClient() { + try ( + // constructorWithDefaultVariables { + final InfluxDBContainer influxDBContainer = new InfluxDBContainer<>( + DockerImageName.parse("influxdb:2.0.7") + ) + // } + ) { + influxDBContainer.start(); + + try (final InfluxDBClient influxDBClient = createClient(influxDBContainer)) { + assertThat(influxDBClient).isNotNull(); + assertThat(influxDBClient.ping()).isTrue(); + } + } + } + + @Test + void getInfluxDBClientWithAdminToken() { + try ( + // constructorWithAdminToken { + final InfluxDBContainer influxDBContainer = new InfluxDBContainer<>( + DockerImageName.parse("influxdb:2.0.7") + ) + .withAdminToken(ADMIN_TOKEN) + // } + ) { + influxDBContainer.start(); + final Optional adminToken = influxDBContainer.getAdminToken(); + assertThat(adminToken).isNotEmpty(); + + try ( + final InfluxDBClient influxDBClient = createClientWithToken( + influxDBContainer.getUrl(), + adminToken.get() + ) + ) { + assertThat(influxDBClient).isNotNull(); + assertThat(influxDBClient.ping()).isTrue(); + } + } + } + + @Test + void getBucket() { + try ( + // constructorWithCustomVariables { + final InfluxDBContainer influxDBContainer = new InfluxDBContainer<>( + DockerImageName.parse("influxdb:2.0.7") + ) + .withUsername(USERNAME) + .withPassword(PASSWORD) + .withOrganization(ORG) + .withBucket(BUCKET) + .withRetention(RETENTION); + // } + ) { + influxDBContainer.start(); + + try (final InfluxDBClient influxDBClient = createClient(influxDBContainer)) { + final Bucket bucket = influxDBClient.getBucketsApi().findBucketByName(BUCKET); + assertThat(bucket).isNotNull(); + + assertThat(bucket.getName()).isEqualTo(BUCKET); + assertThat(bucket.getRetentionRules()) + .hasSize(1) + .first() + .extracting(BucketRetentionRules::getEverySeconds) + .isEqualTo(SECONDS_IN_WEEK); + } + } + } + + @Test + void queryForWriteAndRead() { + try ( + final InfluxDBContainer influxDBContainer = new InfluxDBContainer<>( + InfluxDBTestUtils.INFLUXDB_V2_TEST_IMAGE + ) + .withUsername(USERNAME) + .withPassword(PASSWORD) + .withOrganization(ORG) + .withBucket(BUCKET) + .withRetention(RETENTION) + ) { + influxDBContainer.start(); + + try (final InfluxDBClient influxDBClient = createClient(influxDBContainer)) { + try (final WriteApi writeApi = influxDBClient.makeWriteApi()) { + final Point point = Point + .measurement("temperature") + .addTag("location", "west") + .addField("value", 55.0D) + .time(Instant.now().toEpochMilli(), WritePrecision.MS); + + writeApi.writePoint(point); + } + + final String flux = String.format("from(bucket:\"%s\") |> range(start: 0)", BUCKET); + + final QueryApi queryApi = influxDBClient.getQueryApi(); + + final FluxTable fluxTable = queryApi.query(flux).get(0); + final List records = fluxTable.getRecords(); + assertThat(records).hasSize(1); + } + } + } + + // createInfluxDBClient { + public static InfluxDBClient createClient(final InfluxDBContainer influxDBContainer) { + final InfluxDBClientOptions influxDBClientOptions = InfluxDBClientOptions + .builder() + .url(influxDBContainer.getUrl()) + .authenticate(influxDBContainer.getUsername(), influxDBContainer.getPassword().toCharArray()) + .bucket(influxDBContainer.getBucket()) + .org(influxDBContainer.getOrganization()) + .build(); + return InfluxDBClientFactory.create(influxDBClientOptions); + } + + // } + + public static InfluxDBClient createClientWithToken(final String url, final String token) { + return InfluxDBClientFactory.create(url, token.toCharArray()); + } +} diff --git a/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerV1Test.java b/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerV1Test.java new file mode 100644 index 00000000000..ea074cee68e --- /dev/null +++ b/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerV1Test.java @@ -0,0 +1,129 @@ +package org.testcontainers.containers; + +import org.influxdb.InfluxDB; +import org.influxdb.InfluxDBFactory; +import org.influxdb.dto.Point; +import org.influxdb.dto.Query; +import org.influxdb.dto.QueryResult; +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.DockerImageName; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class InfluxDBContainerV1Test { + + private static final String TEST_VERSION = InfluxDBTestUtils.INFLUXDB_V1_TEST_IMAGE.getVersionPart(); + + private static final String DATABASE = "test"; + + private static final String USER = "new-test-user"; + + private static final String PASSWORD = "new-test-password"; + + @Test + void createInfluxDBOnlyWithUrlAndCorrectVersion() { + try ( + // constructorWithDefaultVariables { + final InfluxDBContainer influxDBContainer = new InfluxDBContainer<>( + DockerImageName.parse("influxdb:1.4.3") + ) + // } + ) { + // Start the container. This step might take some time... + influxDBContainer.start(); + + try (final InfluxDB influxDBClient = createInfluxDBWithUrl(influxDBContainer)) { + assertThat(influxDBClient).isNotNull(); + assertThat(influxDBClient.ping().isGood()).isTrue(); + assertThat(influxDBClient.version()).isEqualTo(TEST_VERSION); + } + } + } + + @Test + void getNewInfluxDBWithCorrectVersion() { + try ( + final InfluxDBContainer influxDBContainer = new InfluxDBContainer<>( + InfluxDBTestUtils.INFLUXDB_V1_TEST_IMAGE + ) + ) { + // Start the container. This step might take some time... + influxDBContainer.start(); + + try (final InfluxDB influxDBClient = createInfluxDBWithUrl(influxDBContainer)) { + assertThat(influxDBClient).isNotNull(); + assertThat(influxDBClient.ping().isGood()).isTrue(); + assertThat(influxDBClient.version()).isEqualTo(TEST_VERSION); + } + } + } + + @Test + void describeDatabases() { + try ( + // constructorWithUserPassword { + final InfluxDBContainer influxDBContainer = new InfluxDBContainer<>( + DockerImageName.parse("influxdb:1.4.3") + ) + .withDatabase(DATABASE) + .withUsername(USER) + .withPassword(PASSWORD) + // } + ) { + // Start the container. This step might take some time... + influxDBContainer.start(); + + try (final InfluxDB influxDBClient = createInfluxDBWithUrl(influxDBContainer)) { + assertThat(influxDBClient.describeDatabases()).contains(DATABASE); + } + } + } + + @Test + void queryForWriteAndRead() { + try ( + final InfluxDBContainer influxDBContainer = new InfluxDBContainer<>( + InfluxDBTestUtils.INFLUXDB_V1_TEST_IMAGE + ) + .withDatabase(DATABASE) + .withUsername(USER) + .withPassword(PASSWORD) + ) { + // Start the container. This step might take some time... + influxDBContainer.start(); + + try (final InfluxDB influxDBClient = createInfluxDBWithUrl(influxDBContainer)) { + final Point point = Point + .measurement("cpu") + .time(System.currentTimeMillis(), TimeUnit.MILLISECONDS) + .addField("idle", 90L) + .addField("user", 9L) + .addField("system", 1L) + .build(); + influxDBClient.write(point); + + final Query query = new Query("SELECT idle FROM cpu", DATABASE); + final QueryResult actual = influxDBClient.query(query); + + assertThat(actual).isNotNull(); + assertThat(actual.getError()).isNull(); + assertThat(actual.getResults()).isNotNull(); + assertThat(actual.getResults()).hasSize(1); + } + } + } + + // createInfluxDBClient { + public static InfluxDB createInfluxDBWithUrl(final InfluxDBContainer container) { + InfluxDB influxDB = InfluxDBFactory.connect( + container.getUrl(), + container.getUsername(), + container.getPassword() + ); + influxDB.setDatabase(container.getDatabase()); + return influxDB; + } + // } +} diff --git a/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBTestUtils.java b/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBTestUtils.java new file mode 100644 index 00000000000..db9ae2bf79f --- /dev/null +++ b/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBTestUtils.java @@ -0,0 +1,10 @@ +package org.testcontainers.containers; + +import org.testcontainers.utility.DockerImageName; + +public final class InfluxDBTestUtils { + + static final DockerImageName INFLUXDB_V1_TEST_IMAGE = DockerImageName.parse("influxdb:1.4.3"); + + static final DockerImageName INFLUXDB_V2_TEST_IMAGE = DockerImageName.parse("influxdb:2.0.7"); +} diff --git a/modules/influxdb/src/test/resources/logback-test.xml b/modules/influxdb/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/influxdb/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/jdbc-test/build.gradle b/modules/jdbc-test/build.gradle new file mode 100644 index 00000000000..44fe705a255 --- /dev/null +++ b/modules/jdbc-test/build.gradle @@ -0,0 +1,15 @@ +dependencies { + api project(':testcontainers-jdbc') + + api 'com.google.guava:guava:33.5.0-jre' + api 'org.apache.commons:commons-lang3:3.20.0' + api 'com.zaxxer:HikariCP-java6:2.3.13' + api 'commons-dbutils:commons-dbutils:1.8.1' + + api 'org.assertj:assertj-core:3.27.7' + + api 'org.apache.tomcat:tomcat-jdbc:11.0.23' + api 'org.vibur:vibur-dbcp:26.0' + api 'com.mysql:mysql-connector-j:9.6.0' + api 'org.junit.jupiter:junit-jupiter:5.14.3' +} diff --git a/modules/jdbc-test/pom.xml b/modules/jdbc-test/pom.xml deleted file mode 100644 index c7b274726bf..00000000000 --- a/modules/jdbc-test/pom.xml +++ /dev/null @@ -1,131 +0,0 @@ - - - 4.0.0 - - - org.testcontainers - testcontainers-parent - 1.2.1-SNAPSHOT - ../../pom.xml - - - jdbc-test - TestContainers :: JDBC :: Tests - - - - ${project.groupId} - mysql - ${project.version} - - - ${project.groupId} - postgresql - ${project.version} - - - ${project.groupId} - mariadb - ${project.version} - - - - - org.postgresql - postgresql - 9.3-1101-jdbc41 - test - - - mysql - mysql-connector-java - 5.1.35 - test - - - org.mariadb.jdbc - mariadb-java-client - 1.4.6 - test - - - - - com.zaxxer - HikariCP-java6 - 2.3.8 - test - - - org.apache.tomcat - tomcat-jdbc - 8.5.4 - - - org.vibur - vibur-dbcp - 9.0 - - - - commons-dbutils - commons-dbutils - 1.6 - test - - - - - - - org.apache.maven.plugins - maven-deploy-plugin - 2.7 - - true - - - - - - - - proprietary-deps - - - ${project.groupId} - oracle-xe - ${project.version} - - - - - com.oracle - ojdbc6 - 11.2.0.4 - provided - - - - - - maven-surefire-plugin - 2.19.1 - - - test-proprietary-deps - - test - - - src/testProprietary/java - - - - - - - - - diff --git a/modules/jdbc-test/src/test/resources/somepath/init_mysql.sql b/modules/jdbc-test/sql/init_mysql.sql similarity index 100% rename from modules/jdbc-test/src/test/resources/somepath/init_mysql.sql rename to modules/jdbc-test/sql/init_mysql.sql diff --git a/modules/jdbc-test/src/main/java/org/testcontainers/db/AbstractContainerDatabaseTest.java b/modules/jdbc-test/src/main/java/org/testcontainers/db/AbstractContainerDatabaseTest.java new file mode 100644 index 00000000000..6d3080c7ee3 --- /dev/null +++ b/modules/jdbc-test/src/main/java/org/testcontainers/db/AbstractContainerDatabaseTest.java @@ -0,0 +1,33 @@ +package org.testcontainers.db; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.testcontainers.containers.JdbcDatabaseContainer; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +public abstract class AbstractContainerDatabaseTest { + + protected ResultSet performQuery(JdbcDatabaseContainer container, String sql) throws SQLException { + DataSource ds = getDataSource(container); + Statement statement = ds.getConnection().createStatement(); + statement.execute(sql); + ResultSet resultSet = statement.getResultSet(); + + resultSet.next(); + return resultSet; + } + + protected DataSource getDataSource(JdbcDatabaseContainer container) { + HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(container.getJdbcUrl()); + hikariConfig.setUsername(container.getUsername()); + hikariConfig.setPassword(container.getPassword()); + hikariConfig.setDriverClassName(container.getDriverClassName()); + return new HikariDataSource(hikariConfig); + } +} diff --git a/modules/jdbc-test/src/main/java/org/testcontainers/jdbc/AbstractJDBCDriverTest.java b/modules/jdbc-test/src/main/java/org/testcontainers/jdbc/AbstractJDBCDriverTest.java new file mode 100644 index 00000000000..95f9d5d1ad4 --- /dev/null +++ b/modules/jdbc-test/src/main/java/org/testcontainers/jdbc/AbstractJDBCDriverTest.java @@ -0,0 +1,230 @@ +package org.testcontainers.jdbc; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.apache.commons.dbutils.QueryRunner; +import org.apache.commons.lang3.SystemUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.MethodSource; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.EnumSet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +@ParameterizedClass +@MethodSource("data") +public class AbstractJDBCDriverTest { + + protected enum Options { + ScriptedSchema, + CharacterSet, + CustomIniFile, + JDBCParams, + PmdKnownBroken, + } + + @Parameter(0) + public String jdbcUrl; + + @Parameter(1) + public EnumSet options; + + public static void sampleInitFunction(Connection connection) throws SQLException { + connection.createStatement().execute("CREATE TABLE bar (\n" + " foo VARCHAR(255)\n" + ");"); + connection.createStatement().execute("INSERT INTO bar (foo) VALUES ('hello world');"); + connection.createStatement().execute("CREATE TABLE my_counter (\n" + " n INT\n" + ");"); + } + + @AfterAll + public static void testCleanup() { + ContainerDatabaseDriver.killContainers(); + } + + @Test + void test() throws SQLException { + try (HikariDataSource dataSource = getDataSource(jdbcUrl, 1)) { + performSimpleTest(dataSource); + + if (options.contains(Options.ScriptedSchema)) { + performTestForScriptedSchema(dataSource); + } + + if (options.contains(Options.JDBCParams)) { + performTestForJDBCParamUsage(dataSource); + } + + if (options.contains(Options.CharacterSet)) { + performSimpleTestWithCharacterSet(jdbcUrl); + + performTestForCharacterEncodingForInitialScriptConnection(dataSource); + } + + if (options.contains(Options.CustomIniFile)) { + performTestForCustomIniFile(dataSource); + } + } + } + + private void performSimpleTest(HikariDataSource dataSource) throws SQLException { + String query = "SELECT 1"; + if (jdbcUrl.startsWith("jdbc:tc:db2:")) { + query = "SELECT 1 FROM SYSIBM.SYSDUMMY1"; + } + + boolean result = new QueryRunner(dataSource, options.contains(Options.PmdKnownBroken)) + .query( + query, + rs -> { + rs.next(); + int resultSetInt = rs.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + return true; + } + ); + + assertThat(result).as("The database returned a record as expected").isTrue(); + } + + private void performTestForScriptedSchema(HikariDataSource dataSource) throws SQLException { + boolean result = new QueryRunner(dataSource) + .query( + "SELECT foo FROM bar WHERE foo LIKE '%world'", + rs -> { + rs.next(); + String resultSetString = rs.getString(1); + assertThat(resultSetString) + .as("A basic SELECT query succeeds where the schema has been applied from a script") + .isEqualTo("hello world"); + return true; + } + ); + + assertThat(result).as("The database returned a record as expected").isTrue(); + } + + private void performTestForJDBCParamUsage(HikariDataSource dataSource) throws SQLException { + boolean result = new QueryRunner(dataSource) + .query( + "select CURRENT_USER", + rs -> { + rs.next(); + String resultUser = rs.getString(1); + // Not all databases (eg. Postgres) return @% at the end of user name. We just need to make sure the user name matches. + if (resultUser.endsWith("@%")) { + resultUser = resultUser.substring(0, resultUser.length() - 2); + } + assertThat(resultUser).as("User from query param is created.").isEqualTo("someuser"); + return true; + } + ); + + assertThat(result).as("The database returned a record as expected").isTrue(); + + String databaseQuery = "SELECT DATABASE()"; + // Postgres does not have Database() as a function + String databaseType = ConnectionUrl.newInstance(jdbcUrl).getDatabaseType(); + if ( + databaseType.equalsIgnoreCase("postgresql") || + databaseType.equalsIgnoreCase("postgis") || + databaseType.equalsIgnoreCase("timescaledb") || + databaseType.equalsIgnoreCase("pgvector") + ) { + databaseQuery = "SELECT CURRENT_DATABASE()"; + } + + result = + new QueryRunner(dataSource) + .query( + databaseQuery, + rs -> { + rs.next(); + String resultDB = rs.getString(1); + assertThat(resultDB).as("Database name from URL String is used.").isEqualTo("databasename"); + return true; + } + ); + + assertThat(result).as("The database returned a record as expected").isTrue(); + } + + private void performTestForCharacterEncodingForInitialScriptConnection(HikariDataSource dataSource) + throws SQLException { + boolean result = new QueryRunner(dataSource) + .query( + "SELECT foo FROM bar WHERE foo LIKE '%мир'", + rs -> { + rs.next(); + String resultSetString = rs.getString(1); + assertThat(resultSetString) + .as("A basic SELECT query succeeds where the schema has been applied from a script") + .isEqualTo("привет мир"); + return true; + } + ); + + assertThat(result).as("The database returned a record as expected").isTrue(); + } + + /** + * This method intentionally verifies encoding twice to ensure that the query string parameters are used when + * Connections are created from cached containers. + * + * @param jdbcUrl + * @throws SQLException + */ + private void performSimpleTestWithCharacterSet(String jdbcUrl) throws SQLException { + HikariDataSource datasource1 = verifyCharacterSet(jdbcUrl); + HikariDataSource datasource2 = verifyCharacterSet(jdbcUrl); + datasource1.close(); + datasource2.close(); + } + + private HikariDataSource verifyCharacterSet(String jdbcUrl) throws SQLException { + HikariDataSource dataSource = getDataSource(jdbcUrl, 1); + boolean result = new QueryRunner(dataSource) + .query( + "SHOW VARIABLES LIKE 'character\\_set\\_connection'", + rs -> { + rs.next(); + String resultSetString = rs.getString(2); + assertThat(resultSetString) + .as("Passing query parameters to set DB connection encoding is successful") + .startsWith("utf8"); + return true; + } + ); + + assertThat(result).as("The database returned a record as expected").isTrue(); + return dataSource; + } + + private void performTestForCustomIniFile(HikariDataSource dataSource) throws SQLException { + assumeThat(SystemUtils.IS_OS_WINDOWS).isFalse(); + Statement statement = dataSource.getConnection().createStatement(); + statement.execute("SELECT @@GLOBAL.innodb_max_undo_log_size"); + ResultSet resultSet = statement.getResultSet(); + + assertThat(resultSet.next()).as("The query returns a result").isTrue(); + long result = resultSet.getLong(1); + + assertThat(result).as("The InnoDB max undo log size has been set by the ini file content").isEqualTo(20000000); + } + + private HikariDataSource getDataSource(String jdbcUrl, int poolSize) { + HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(jdbcUrl); + hikariConfig.setConnectionTestQuery("SELECT 1"); + hikariConfig.setMinimumIdle(1); + hikariConfig.setMaximumPoolSize(poolSize); + + return new HikariDataSource(hikariConfig); + } +} diff --git a/modules/jdbc-test/src/main/resources/logback-test.xml b/modules/jdbc-test/src/main/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/jdbc-test/src/main/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/jdbc-test/src/test/java/org/testcontainers/jdbc/JDBCDriverTest.java b/modules/jdbc-test/src/test/java/org/testcontainers/jdbc/JDBCDriverTest.java deleted file mode 100644 index f29a19c14f1..00000000000 --- a/modules/jdbc-test/src/test/java/org/testcontainers/jdbc/JDBCDriverTest.java +++ /dev/null @@ -1,150 +0,0 @@ -package org.testcontainers.jdbc; - -import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; -import org.apache.commons.dbutils.QueryRunner; -import org.apache.commons.lang.SystemUtils; -import org.junit.AfterClass; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.junit.runners.Parameterized.Parameter; - -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; - -import static java.util.Arrays.asList; -import static org.junit.Assume.assumeFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; - -@RunWith(Parameterized.class) -public class JDBCDriverTest { - - @Parameter - public String jdbcUrl; - @Parameter(1) - public boolean performTestForScriptedSchema; - @Parameter(2) - public boolean performTestForCharacterSet; - @Parameter(3) - public boolean performTestForCustomIniFile; - - @Parameterized.Parameters(name = "{index} - {0}") - public static Iterable data() { - return asList( - new Object[][]{ - {"jdbc:tc:mysql:5.5.43://hostname/databasename", false, false, false}, - {"jdbc:tc:mysql://hostname/databasename?TC_INITSCRIPT=somepath/init_mysql.sql", true, false, false}, - {"jdbc:tc:mysql://hostname/databasename?TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction", true, false, false}, - {"jdbc:tc:mysql://hostname/databasename?useUnicode=yes&characterEncoding=utf8", false, true, false}, - {"jdbc:tc:mysql://hostname/databasename", false, false, false}, - {"jdbc:tc:postgresql://hostname/databasename", false, false, false}, - {"jdbc:tc:mariadb:10.1.16://hostname/databasename", false, false, false}, - {"jdbc:tc:mariadb://hostname/databasename", false, false, false}, - {"jdbc:tc:mariadb://hostname/databasename?useUnicode=yes&characterEncoding=utf8", false, true, false}, - {"jdbc:tc:mariadb://hostname/databasename?TC_INITSCRIPT=somepath/init_mariadb.sql", true, false, false}, - {"jdbc:tc:mariadb://hostname/databasename?TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction", true, false, false}, - {"jdbc:tc:mysql:5.6://hostname/databasename?TC_MY_CNF=somepath/mysql_conf_override", false, false, true}, - {"jdbc:tc:mariadb:10.1.16://hostname/databasename?TC_MY_CNF=somepath/mariadb_conf_override", false, false, true} - }); - } - - public static void sampleInitFunction(Connection connection) throws SQLException { - connection.createStatement().execute("CREATE TABLE bar (\n" + - " foo VARCHAR(255)\n" + - ");"); - connection.createStatement().execute("INSERT INTO bar (foo) VALUES ('hello world');"); - connection.createStatement().execute("CREATE TABLE my_counter (\n" + - " n INT\n" + - ");"); - } - - @AfterClass - public static void testCleanup() { - ContainerDatabaseDriver.killContainers(); - } - - @Test - public void test() throws SQLException { - performSimpleTest(jdbcUrl); - - if (performTestForScriptedSchema) { - performTestForScriptedSchema(jdbcUrl); - } - - if (performTestForCharacterSet) { - performSimpleTestWithCharacterSet(jdbcUrl); - } - - if (performTestForCustomIniFile) { - performTestForCustomIniFile(jdbcUrl); - } - } - - private void performSimpleTest(String jdbcUrl) throws SQLException { - try (HikariDataSource dataSource = getDataSource(jdbcUrl, 1)) { - boolean result = new QueryRunner(dataSource).query("SELECT 1", rs -> { - rs.next(); - int resultSetInt = rs.getInt(1); - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - return true; - }); - - assertTrue("The database returned a record as expected", result); - } - } - - private void performTestForScriptedSchema(String jdbcUrl) throws SQLException { - try (HikariDataSource dataSource = getDataSource(jdbcUrl, 1)) { - boolean result = new QueryRunner(dataSource).query("SELECT foo FROM bar WHERE foo LIKE '%world'", rs -> { - rs.next(); - String resultSetString = rs.getString(1); - assertEquals("A basic SELECT query succeeds where the schema has been applied from a script", "hello world", resultSetString); - return true; - }); - - assertTrue("The database returned a record as expected", result); - - } - } - - private void performSimpleTestWithCharacterSet(String jdbcUrl) throws SQLException { - try (HikariDataSource dataSource = getDataSource(jdbcUrl, 1)) { - boolean result = new QueryRunner(dataSource).query("SHOW VARIABLES LIKE 'character\\_set\\_connection'", rs -> { - rs.next(); - String resultSetInt = rs.getString(2); - assertEquals("Passing query parameters to set DB connection encoding is successful", "utf8", resultSetInt); - return true; - }); - - assertTrue("The database returned a record as expected", result); - } - } - - private void performTestForCustomIniFile(final String jdbcUrl) throws SQLException { - assumeFalse(SystemUtils.IS_OS_WINDOWS); - try (HikariDataSource ds = getDataSource(jdbcUrl, 1)) { - Statement statement = ds.getConnection().createStatement(); - statement.execute("SELECT @@GLOBAL.innodb_file_format"); - ResultSet resultSet = statement.getResultSet(); - - assertTrue("The query returns a result", resultSet.next()); - String result = resultSet.getString(1); - - assertEquals("The InnoDB file format has been set by the ini file content", "Barracuda", result); - } - } - - private HikariDataSource getDataSource(String jdbcUrl, int poolSize) { - HikariConfig hikariConfig = new HikariConfig(); - hikariConfig.setJdbcUrl(jdbcUrl); - hikariConfig.setConnectionTestQuery("SELECT 1"); - hikariConfig.setMinimumIdle(1); - hikariConfig.setMaximumPoolSize(poolSize); - - return new HikariDataSource(hikariConfig); - } -} diff --git a/modules/jdbc-test/src/test/java/org/testcontainers/junit/CustomizablePostgreSQLTest.java b/modules/jdbc-test/src/test/java/org/testcontainers/junit/CustomizablePostgreSQLTest.java deleted file mode 100644 index 8180ae2a872..00000000000 --- a/modules/jdbc-test/src/test/java/org/testcontainers/junit/CustomizablePostgreSQLTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.testcontainers.junit; - -import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; -import org.junit.Rule; -import org.junit.Test; -import org.testcontainers.containers.PostgreSQLContainer; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; - -/** - * @author richardnorth - */ -public class CustomizablePostgreSQLTest { - private static final String DB_NAME = "foo"; - private static final String USER = "bar"; - private static final String PWD = "baz"; - - @Rule - public PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:latest") - .withDatabaseName(DB_NAME) - .withUsername(USER) - .withPassword(PWD); - - @Test - public void testSimple() throws SQLException { - HikariConfig hikariConfig = new HikariConfig(); - hikariConfig.setJdbcUrl("jdbc:postgresql://" - + postgres.getContainerIpAddress() - + ":" + postgres.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT) - + "/" + DB_NAME); - hikariConfig.setUsername(USER); - hikariConfig.setPassword(PWD); - - HikariDataSource ds = new HikariDataSource(hikariConfig); - Statement statement = ds.getConnection().createStatement(); - statement.execute("SELECT 1"); - ResultSet resultSet = statement.getResultSet(); - - resultSet.next(); - int resultSetInt = resultSet.getInt(1); - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } -} diff --git a/modules/jdbc-test/src/test/java/org/testcontainers/junit/SimpleMariaDBTest.java b/modules/jdbc-test/src/test/java/org/testcontainers/junit/SimpleMariaDBTest.java deleted file mode 100644 index 1ad65e65465..00000000000 --- a/modules/jdbc-test/src/test/java/org/testcontainers/junit/SimpleMariaDBTest.java +++ /dev/null @@ -1,87 +0,0 @@ -package org.testcontainers.junit; - -import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; -import lombok.NonNull; - -import org.apache.commons.lang.SystemUtils; -import org.junit.Test; -import org.testcontainers.containers.MariaDBContainer; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.junit.Assume.assumeFalse; - - -/** - * @author Miguel Gonzalez Sanchez - */ -public class SimpleMariaDBTest { - - @Test - public void testSimple() throws SQLException { - MariaDBContainer mariadb = new MariaDBContainer(); - mariadb.start(); - - try { - ResultSet resultSet = performQuery(mariadb, "SELECT 1"); - int resultSetInt = resultSet.getInt(1); - - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } finally { - mariadb.stop(); - } - } - - @Test - public void testSpecificVersion() throws SQLException { - MariaDBContainer mariadbOldVersion = new MariaDBContainer("mariadb:5.5.51"); - mariadbOldVersion.start(); - - try { - ResultSet resultSet = performQuery(mariadbOldVersion, "SELECT VERSION()"); - String resultSetString = resultSet.getString(1); - - assertTrue("The database version can be set using a container rule parameter", resultSetString.startsWith("5.5.51")); - } finally { - mariadbOldVersion.stop(); - } - } - - @Test - public void testMariaDBWithCustomIniFile() throws SQLException { - assumeFalse(SystemUtils.IS_OS_WINDOWS); - MariaDBContainer mariadbCustomConfig = new MariaDBContainer("mariadb:10.1.16") - .withConfigurationOverride("somepath/mariadb_conf_override"); - mariadbCustomConfig.start(); - - try { - ResultSet resultSet = performQuery(mariadbCustomConfig, "SELECT @@GLOBAL.innodb_file_format"); - String result = resultSet.getString(1); - - assertEquals("The InnoDB file format has been set by the ini file content", "Barracuda", result); - } finally { - mariadbCustomConfig.stop(); - } - } - - @NonNull - protected ResultSet performQuery(MariaDBContainer containerRule, String sql) throws SQLException { - HikariConfig hikariConfig = new HikariConfig(); - hikariConfig.setJdbcUrl(containerRule.getJdbcUrl()); - hikariConfig.setUsername(containerRule.getUsername()); - hikariConfig.setPassword(containerRule.getPassword()); - - HikariDataSource ds = new HikariDataSource(hikariConfig); - Statement statement = ds.getConnection().createStatement(); - statement.execute(sql); - ResultSet resultSet = statement.getResultSet(); - - resultSet.next(); - return resultSet; - } -} diff --git a/modules/jdbc-test/src/test/java/org/testcontainers/junit/SimpleMySQLTest.java b/modules/jdbc-test/src/test/java/org/testcontainers/junit/SimpleMySQLTest.java deleted file mode 100644 index b7169f6d6aa..00000000000 --- a/modules/jdbc-test/src/test/java/org/testcontainers/junit/SimpleMySQLTest.java +++ /dev/null @@ -1,112 +0,0 @@ -package org.testcontainers.junit; - -import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; -import lombok.NonNull; -import org.apache.commons.lang.SystemUtils; -import org.junit.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.containers.output.Slf4jLogConsumer; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; - -import static org.junit.Assume.assumeFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; - - -/** - * @author richardnorth - */ -public class SimpleMySQLTest { - - private static final Logger logger = LoggerFactory.getLogger(SimpleMySQLTest.class); - - /* - * Ordinarily you wouldn't try and run multiple containers simultaneously - this is just used for testing. - * To avoid memory issues with the default, low memory, docker machine setup, we instantiate only one container - * at a time, inside the test methods themselves. - */ - /* - @ClassRule - public static MySQLContainer mysql = new MySQLContainer(); - - @ClassRule - public static MySQLContainer mysqlOldVersion = new MySQLContainer("mysql:5.5"); - - @ClassRule - public static MySQLContainer mysqlCustomConfig = new MySQLContainer("mysql:5.6") - .withConfigurationOverride("somepath/mysql_conf_override"); - */ - - @Test - public void testSimple() throws SQLException { - MySQLContainer mysql = (MySQLContainer) new MySQLContainer() - .withConfigurationOverride("somepath/mysql_conf_override") - .withLogConsumer(new Slf4jLogConsumer(logger)); - mysql.start(); - - try { - ResultSet resultSet = performQuery(mysql, "SELECT 1"); - int resultSetInt = resultSet.getInt(1); - - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } finally { - mysql.stop(); - } - } - - @Test - public void testSpecificVersion() throws SQLException { - MySQLContainer mysqlOldVersion = (MySQLContainer) new MySQLContainer("mysql:5.5") - .withConfigurationOverride("somepath/mysql_conf_override") - .withLogConsumer(new Slf4jLogConsumer(logger)); - mysqlOldVersion.start(); - - try { - ResultSet resultSet = performQuery(mysqlOldVersion, "SELECT VERSION()"); - String resultSetString = resultSet.getString(1); - - assertTrue("The database version can be set using a container rule parameter", resultSetString.startsWith("5.5")); - } finally { - mysqlOldVersion.stop(); - } - } - - @Test - public void testMySQLWithCustomIniFile() throws SQLException { - assumeFalse(SystemUtils.IS_OS_WINDOWS); - MySQLContainer mysqlCustomConfig = new MySQLContainer("mysql:5.6") - .withConfigurationOverride("somepath/mysql_conf_override"); - mysqlCustomConfig.start(); - - try { - ResultSet resultSet = performQuery(mysqlCustomConfig, "SELECT @@GLOBAL.innodb_file_format"); - String result = resultSet.getString(1); - - assertEquals("The InnoDB file format has been set by the ini file content", "Barracuda", result); - } finally { - mysqlCustomConfig.stop(); - } - } - - @NonNull - protected ResultSet performQuery(MySQLContainer containerRule, String sql) throws SQLException { - HikariConfig hikariConfig = new HikariConfig(); - hikariConfig.setJdbcUrl(containerRule.getJdbcUrl()); - hikariConfig.setUsername(containerRule.getUsername()); - hikariConfig.setPassword(containerRule.getPassword()); - - HikariDataSource ds = new HikariDataSource(hikariConfig); - Statement statement = ds.getConnection().createStatement(); - statement.execute(sql); - ResultSet resultSet = statement.getResultSet(); - - resultSet.next(); - return resultSet; - } -} diff --git a/modules/jdbc-test/src/test/java/org/testcontainers/junit/SimplePostgreSQLTest.java b/modules/jdbc-test/src/test/java/org/testcontainers/junit/SimplePostgreSQLTest.java deleted file mode 100644 index 942d8f1c1ba..00000000000 --- a/modules/jdbc-test/src/test/java/org/testcontainers/junit/SimplePostgreSQLTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.testcontainers.junit; - -import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; -import org.junit.Rule; -import org.junit.Test; -import org.testcontainers.containers.PostgreSQLContainer; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; - -/** - * @author richardnorth - */ -public class SimplePostgreSQLTest { - - @Rule - public PostgreSQLContainer postgres = new PostgreSQLContainer(); - - @Test - public void testSimple() throws SQLException { - HikariConfig hikariConfig = new HikariConfig(); - hikariConfig.setJdbcUrl(postgres.getJdbcUrl()); - hikariConfig.setUsername(postgres.getUsername()); - hikariConfig.setPassword(postgres.getPassword()); - - HikariDataSource ds = new HikariDataSource(hikariConfig); - Statement statement = ds.getConnection().createStatement(); - statement.execute("SELECT 1"); - ResultSet resultSet = statement.getResultSet(); - - resultSet.next(); - int resultSetInt = resultSet.getInt(1); - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } -} diff --git a/modules/jdbc-test/src/test/resources/logback-test.xml b/modules/jdbc-test/src/test/resources/logback-test.xml deleted file mode 100644 index ed0e5b2659e..00000000000 --- a/modules/jdbc-test/src/test/resources/logback-test.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - %d{HH:mm:ss.SSS} %-5level %logger - %msg%n - - - - - - - - - - - - - - - - - - - PROFILER - DENY - - \ No newline at end of file diff --git a/modules/jdbc/build.gradle b/modules/jdbc/build.gradle new file mode 100644 index 00000000000..4d538fe2720 --- /dev/null +++ b/modules/jdbc/build.gradle @@ -0,0 +1,14 @@ +description = "Testcontainers :: JDBC" + +dependencies { + api project(':testcontainers-database-commons') + + compileOnly 'org.jetbrains:annotations:26.1.0' + testImplementation 'commons-dbutils:commons-dbutils:1.8.1' + testImplementation 'org.vibur:vibur-dbcp:26.0' + testImplementation 'org.apache.tomcat:tomcat-jdbc:11.0.21' + testImplementation 'com.zaxxer:HikariCP-java6:2.3.13' + testImplementation ('org.mockito:mockito-core:4.11.0') { + exclude(module: 'hamcrest-core') + } +} diff --git a/modules/jdbc/pom.xml b/modules/jdbc/pom.xml deleted file mode 100644 index 83024402cbc..00000000000 --- a/modules/jdbc/pom.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - 4.0.0 - - - org.testcontainers - testcontainers-parent - 1.2.1-SNAPSHOT - ../../pom.xml - - - jdbc - TestContainers :: JDBC - - - - ${project.groupId} - testcontainers - ${project.version} - - - com.google.guava - guava - 18.0 - provided - - - - - - - maven-shade-plugin - 2.4.3 - - - package - - shade - - - - - - - - - - - com.google.common - org.testcontainers.shaded.com.google.common - - - org.glassfish - org.testcontainers.shaded.org.glassfish - - - javax.ws.rs - org.testcontainers.shaded.javax.ws.rs - - - com.fasterxml.jackson - org.testcontainers.shaded.com.fasterxml.jackson - - - - - org.bouncycastle:* - - META-INF/*.SF - META-INF/*.DSA - META-INF/*.RSA - - - - *:* - - META-INF/services/com.fasterxml.jackson.core.* - META-INF/services/com.github.dockerjava.api.command.* - META-INF/services/javax.ws.rs.ext.* - META-INF/services/org.glassfish.hk2.extension.* - META-INF/services/org.jvnet.hk2.external.generator.* - META-INF/services/org.glassfish.jersey.internal.spi.* - - - - - - com.google.guava:* - org.glassfish.*:* - - - true - false - - - - - diff --git a/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainer.java b/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainer.java index 21a9da73c95..cf6c995528f 100644 --- a/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainer.java +++ b/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainer.java @@ -1,50 +1,88 @@ package org.testcontainers.containers; +import com.github.dockerjava.api.command.InspectContainerResponse; +import lombok.NonNull; +import lombok.SneakyThrows; +import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; -import org.rnorth.ducttape.ratelimits.RateLimiter; -import org.rnorth.ducttape.ratelimits.RateLimiterBuilder; -import org.rnorth.ducttape.unreliables.Unreliables; +import org.jetbrains.annotations.Nullable; import org.testcontainers.containers.traits.LinkableContainer; +import org.testcontainers.delegate.DatabaseDelegate; +import org.testcontainers.ext.ScriptUtils; +import org.testcontainers.jdbc.JdbcDatabaseDelegate; +import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.MountableFile; import java.sql.Connection; import java.sql.Driver; import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Properties; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; /** * Base class for containers that expose a JDBC connection - * - * @author richardnorth */ -public abstract class JdbcDatabaseContainer> extends GenericContainer implements LinkableContainer { +public abstract class JdbcDatabaseContainer> + extends GenericContainer + implements LinkableContainer { private static final Object DRIVER_LOAD_MUTEX = new Object(); + private Driver driver; + + private List initScriptPaths = new ArrayList<>(); + protected Map parameters = new HashMap<>(); - private static final RateLimiter DB_CONNECT_RATE_LIMIT = RateLimiterBuilder.newBuilder() - .withRate(10, TimeUnit.SECONDS) - .withConstantThroughput() - .build(); + protected Map urlParameters = new HashMap<>(); + + private int startupTimeoutSeconds = 120; + + private int connectTimeoutSeconds = 120; + + private static final String QUERY_PARAM_SEPARATOR = "&"; + + /** + * @deprecated use {@link #JdbcDatabaseContainer(DockerImageName)} instead + */ + public JdbcDatabaseContainer(@NonNull final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } - public JdbcDatabaseContainer(String dockerImageName) { + public JdbcDatabaseContainer(@NonNull final Future image) { + super(image); + } + + public JdbcDatabaseContainer(final DockerImageName dockerImageName) { super(dockerImageName); } /** * @return the name of the actual JDBC driver to use */ - protected abstract String getDriverClassName(); + public abstract String getDriverClassName(); /** * @return a JDBC URL that may be used to connect to the dockerized DB */ public abstract String getJdbcUrl(); + /** + * @return the database name + */ + public String getDatabaseName() { + throw new UnsupportedOperationException(); + } + /** * @return the standard database username that should be used for connections */ @@ -60,42 +98,141 @@ public JdbcDatabaseContainer(String dockerImageName) { */ protected abstract String getTestQueryString(); + public SELF withUsername(String username) { + throw new UnsupportedOperationException(); + } + + public SELF withPassword(String password) { + throw new UnsupportedOperationException(); + } + + public SELF withDatabaseName(String dbName) { + throw new UnsupportedOperationException(); + } + + public SELF withUrlParam(String paramName, String paramValue) { + urlParameters.put(paramName, paramValue); + return self(); + } + + /** + * Set startup time to allow, including image pull time, in seconds. + * + * @param startupTimeoutSeconds startup time to allow, including image pull time, in seconds + * @return self + */ + public SELF withStartupTimeoutSeconds(int startupTimeoutSeconds) { + this.startupTimeoutSeconds = startupTimeoutSeconds; + return self(); + } + + /** + * Set time to allow for the database to start and establish an initial connection, in seconds. + * + * @param connectTimeoutSeconds time to allow for the database to start and establish an initial connection in seconds + * @return self + */ + public SELF withConnectTimeoutSeconds(int connectTimeoutSeconds) { + this.connectTimeoutSeconds = connectTimeoutSeconds; + return self(); + } + + /** + * Sets a script for initialization. + * + * @param initScriptPath path to the script file + * @return self + */ + public SELF withInitScript(String initScriptPath) { + this.initScriptPaths = new ArrayList<>(); + this.initScriptPaths.add(initScriptPath); + return self(); + } + + /** + * Sets an ordered array of scripts for initialization. + * + * @param initScriptPaths paths to the script files + * @return self + */ + public SELF withInitScripts(String... initScriptPaths) { + return withInitScripts(Arrays.asList(initScriptPaths)); + } + + /** + * Sets an ordered collection of scripts for initialization. + * + * @param initScriptPaths paths to the script files + * @return self + */ + public SELF withInitScripts(Iterable initScriptPaths) { + this.initScriptPaths = new ArrayList<>(); + initScriptPaths.forEach(this.initScriptPaths::add); + return self(); + } + + @SneakyThrows(InterruptedException.class) @Override protected void waitUntilContainerStarted() { - // Repeatedly try and open a connection to the DB and execute a test query + logger() + .info( + "Waiting for database connection to become available at {} using query '{}'", + getJdbcUrl(), + getTestQueryString() + ); - logger().info("Waiting for database connection to become available at {} using query '{}'", getJdbcUrl(), getTestQueryString()); - Unreliables.retryUntilSuccess(120, TimeUnit.SECONDS, () -> { + // Repeatedly try and open a connection to the DB and execute a test query + long start = System.nanoTime(); + Exception lastConnectionException = null; + while ((System.nanoTime() - start) < TimeUnit.SECONDS.toNanos(startupTimeoutSeconds)) { if (!isRunning()) { - throw new ContainerLaunchException("Container failed to start"); + Thread.sleep(100L); + } else { + try (Connection connection = createConnection(""); Statement statement = connection.createStatement()) { + boolean testQuerySucceeded = statement.execute(this.getTestQueryString()); + if (testQuerySucceeded) { + return; + } + } catch (NoDriverFoundException e) { + // we explicitly want this exception to fail fast without retries + throw e; + } catch (Exception e) { + lastConnectionException = e; + // ignore so that we can try again + logger().debug("Failure when trying test query", e); + Thread.sleep(100L); + } } + } - Connection connection = DB_CONNECT_RATE_LIMIT.getWhenReady(() -> createConnection("")); - - boolean success = connection.createStatement().execute(JdbcDatabaseContainer.this.getTestQueryString()); + throw new IllegalStateException( + String.format( + "Container is started, but cannot be accessed by (JDBC URL: %s), please check container logs", + this.getJdbcUrl() + ), + lastConnectionException + ); + } - if (success) { - logger().info("Obtained a connection to container ({})", JdbcDatabaseContainer.this.getJdbcUrl()); - return connection; - } else { - throw new SQLException("Failed to execute test query"); - } - }); + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + logger().info("Container is started (JDBC URL: {})", this.getJdbcUrl()); + runInitScriptIfRequired(); } /** * Obtain an instance of the correct JDBC driver for this particular database container type + * * @return a JDBC Driver */ - public Driver getJdbcDriverInstance() { - + public Driver getJdbcDriverInstance() throws NoDriverFoundException { synchronized (DRIVER_LOAD_MUTEX) { if (driver == null) { try { driver = (Driver) Class.forName(this.getDriverClassName()).newInstance(); } catch (InstantiationException | IllegalAccessException | ClassNotFoundException e) { - throw new RuntimeException("Could not get Driver", e); + throw new NoDriverFoundException("Could not get Driver", e); } } } @@ -106,37 +243,130 @@ public Driver getJdbcDriverInstance() { /** * Creates a connection to the underlying containerized database instance. * - * @param queryString any special query string parameters that should be appended to the JDBC connection URL. The - * '?' character must be included - * @return a Connection + * @param queryString query string parameters that should be appended to the JDBC connection URL. + * The '?' character must be included + * @return a Connection + * @throws SQLException if there is a repeated failure to create the connection + */ + public Connection createConnection(String queryString) throws SQLException, NoDriverFoundException { + return createConnection(queryString, new Properties()); + } + + /** + * Creates a connection to the underlying containerized database instance. + * + * @param queryString query string parameters that should be appended to the JDBC connection URL. + * The '?' character must be included + * @param info additional properties to be passed to the JDBC driver + * @return a Connection * @throws SQLException if there is a repeated failure to create the connection */ - public Connection createConnection(String queryString) throws SQLException { - final Properties info = new Properties(); - info.put("user", this.getUsername()); - info.put("password", this.getPassword()); - final String url = this.getJdbcUrl() + queryString; + public Connection createConnection(String queryString, Properties info) + throws SQLException, NoDriverFoundException { + Properties properties = new Properties(info); + properties.put("user", this.getUsername()); + properties.put("password", this.getPassword()); + final String url = constructUrlForConnection(queryString); final Driver jdbcDriverInstance = getJdbcDriverInstance(); + SQLException lastException = null; try { - return Unreliables.retryUntilSuccess(120, TimeUnit.SECONDS, () -> jdbcDriverInstance.connect(url, info)); - } catch (Exception e) { - throw new SQLException("Could not create new connection", e); + long start = System.nanoTime(); + // give up if we hit the time limit or the container stops running for some reason + while ((System.nanoTime() - start < TimeUnit.SECONDS.toNanos(connectTimeoutSeconds)) && isRunning()) { + try { + logger() + .debug( + "Trying to create JDBC connection using {} to {} with properties: {}", + jdbcDriverInstance.getClass().getName(), + url, + properties + ); + + return jdbcDriverInstance.connect(url, properties); + } catch (SQLException e) { + lastException = e; + Thread.sleep(100L); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } + throw new SQLException("Could not create new connection", lastException); } - protected void optionallyMapResourceParameterAsVolume(@NotNull String paramName, @NotNull String pathNameInContainer, @NotNull String defaultResource) { + /** + * Template method for constructing the JDBC URL to be used for creating {@link Connection}s. + * This should be overridden if the JDBC URL and query string concatenation or URL string + * construction needs to be different to normal. + * + * @param queryString query string parameters that should be appended to the JDBC connection URL. + * The '?' character must be included + * @return a full JDBC URL including queryString + */ + protected String constructUrlForConnection(String queryString) { + String baseUrl = getJdbcUrl(); + + if ("".equals(queryString)) { + return baseUrl; + } + + if (!queryString.startsWith("?")) { + throw new IllegalArgumentException("The '?' character must be included"); + } + + return baseUrl.contains("?") + ? baseUrl + QUERY_PARAM_SEPARATOR + queryString.substring(1) + : baseUrl + queryString; + } + + protected String constructUrlParameters(String startCharacter, String delimiter) { + return constructUrlParameters(startCharacter, delimiter, StringUtils.EMPTY); + } + + protected String constructUrlParameters(String startCharacter, String delimiter, String endCharacter) { + String urlParameters = ""; + if (!this.urlParameters.isEmpty()) { + String additionalParameters = + this.urlParameters.entrySet().stream().map(Object::toString).collect(Collectors.joining(delimiter)); + urlParameters = startCharacter + additionalParameters + endCharacter; + } + return urlParameters; + } + + @Deprecated + protected void optionallyMapResourceParameterAsVolume( + @NotNull String paramName, + @NotNull String pathNameInContainer, + @NotNull String defaultResource + ) { + optionallyMapResourceParameterAsVolume(paramName, pathNameInContainer, defaultResource, null); + } + + protected void optionallyMapResourceParameterAsVolume( + @NotNull String paramName, + @NotNull String pathNameInContainer, + @NotNull String defaultResource, + @Nullable Integer fileMode + ) { String resourceName = parameters.getOrDefault(paramName, defaultResource); if (resourceName != null) { - final MountableFile mountableFile = MountableFile.forClasspathResource(resourceName); - addFileSystemBind(mountableFile.getResolvedPath(), pathNameInContainer, BindMode.READ_ONLY); + final MountableFile mountableFile = MountableFile.forClasspathResource(resourceName, fileMode); + withCopyFileToContainer(mountableFile, pathNameInContainer); } } - @Override - protected abstract Integer getLivenessCheckPort(); + /** + * Load init script content and apply it to the database if initScriptPath is set + */ + protected void runInitScriptIfRequired() { + initScriptPaths + .stream() + .filter(Objects::nonNull) + .forEach(path -> ScriptUtils.runInitScript(getDatabaseDelegate(), path)); + } public void setParameters(Map parameters) { this.parameters = parameters; @@ -146,4 +376,33 @@ public void setParameters(Map parameters) { public void addParameter(String paramName, String value) { this.parameters.put(paramName, value); } + + /** + * @return startup time to allow, including image pull time, in seconds + * @deprecated should not be overridden anymore, use {@link #withStartupTimeoutSeconds(int)} in constructor instead + */ + @Deprecated + protected int getStartupTimeoutSeconds() { + return startupTimeoutSeconds; + } + + /** + * @return time to allow for the database to start and establish an initial connection, in seconds + * @deprecated should not be overridden anymore, use {@link #withConnectTimeoutSeconds(int)} in constructor instead + */ + @Deprecated + protected int getConnectTimeoutSeconds() { + return connectTimeoutSeconds; + } + + protected DatabaseDelegate getDatabaseDelegate() { + return new JdbcDatabaseDelegate(this, ""); + } + + public static class NoDriverFoundException extends RuntimeException { + + public NoDriverFoundException(String message, Throwable e) { + super(message, e); + } + } } diff --git a/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainerProvider.java b/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainerProvider.java index dda947b9dd4..3cd74dc98d4 100644 --- a/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainerProvider.java +++ b/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainerProvider.java @@ -1,11 +1,84 @@ package org.testcontainers.containers; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.jdbc.ConnectionUrl; + +import java.util.Objects; + /** * Base class for classes that can provide a JDBC container. */ +@Slf4j public abstract class JdbcDatabaseContainerProvider { + /** + * Tests if the specified database type is supported by this Container Provider. It should match to the base image name. + * @param databaseType {@link String} + * @return true when provider can handle this database type, else false. + */ public abstract boolean supports(String databaseType); + /** + * Instantiate a new {@link JdbcDatabaseContainer} without any specified image tag. Subclasses should + * override this method if possible, to provide a default tag that is more stable than latest`. + * + * @return Instance of {@link JdbcDatabaseContainer} + */ + public JdbcDatabaseContainer newInstance() { + log.warn( + "No explicit version tag was provided in JDBC URL and this class ({}) does not " + + "override newInstance() to set a default tag. `latest` will be used but results may " + + "be unreliable!", + this.getClass().getCanonicalName() + ); + return this.newInstance("latest"); + } + + /** + * Instantiate a new {@link JdbcDatabaseContainer} with specified image tag. + * @param tag + * @return Instance of {@link JdbcDatabaseContainer} + */ public abstract JdbcDatabaseContainer newInstance(String tag); + + /** + * Instantiate a new {@link JdbcDatabaseContainer} using information provided with {@link ConnectionUrl}. + * @param url {@link ConnectionUrl} + * @return Instance of {@link JdbcDatabaseContainer} + */ + public JdbcDatabaseContainer newInstance(ConnectionUrl url) { + final JdbcDatabaseContainer result; + if (url.getImageTag().isPresent()) { + result = newInstance(url.getImageTag().get()); + } else { + result = newInstance(); + } + result.withReuse(url.isReusable()); + return result; + } + + protected JdbcDatabaseContainer newInstanceFromConnectionUrl( + ConnectionUrl connectionUrl, + final String userParamName, + final String pwdParamName + ) { + Objects.requireNonNull(connectionUrl, "Connection URL cannot be null"); + + final String databaseName = connectionUrl.getDatabaseName().orElse("test"); + final String user = connectionUrl.getQueryParameters().getOrDefault(userParamName, "test"); + final String password = connectionUrl.getQueryParameters().getOrDefault(pwdParamName, "test"); + + final JdbcDatabaseContainer instance; + if (connectionUrl.getImageTag().isPresent()) { + instance = newInstance(connectionUrl.getImageTag().get()); + } else { + instance = newInstance(); + } + + return instance + .withReuse(connectionUrl.isReusable()) + .withDatabaseName(databaseName) + .withUsername(user) + .withPassword(password); + } } diff --git a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionDelegate.java b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionDelegate.java new file mode 100644 index 00000000000..47d3cadc823 --- /dev/null +++ b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionDelegate.java @@ -0,0 +1,13 @@ +package org.testcontainers.jdbc; + +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +import java.sql.Connection; + +@RequiredArgsConstructor +class ConnectionDelegate implements Connection { + + @Delegate + private final Connection delegate; +} diff --git a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionUrl.java b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionUrl.java new file mode 100644 index 00000000000..ec606f3ed32 --- /dev/null +++ b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionUrl.java @@ -0,0 +1,271 @@ +package org.testcontainers.jdbc; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import org.testcontainers.UnstableAPI; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * This is an Immutable class holding JDBC Connection Url and its parsed components, used by {@link ContainerDatabaseDriver}. + *

+ * {@link ConnectionUrl#parseUrl()} method must be called after instantiating this class. + */ +@EqualsAndHashCode(of = "url") +@Getter +public class ConnectionUrl { + + private String url; + + private String databaseType; + + private Optional imageTag; + + /** + * This is a part of the connection string that may specify host:port/databasename. + * It may vary for different clients and so clients can parse it as needed. + */ + private String dbHostString; + + private boolean inDaemonMode = false; + + private Optional databaseHost = Optional.empty(); + + private Optional databasePort = Optional.empty(); + + private Optional databaseName = Optional.empty(); + + private Optional initScriptPath = Optional.empty(); + + @UnstableAPI + private boolean reusable = false; + + private Optional initFunction = Optional.empty(); + + private Optional queryString; + + private Map containerParameters; + + private Map queryParameters; + + private Map tmpfsOptions = new HashMap<>(); + + public static ConnectionUrl newInstance(final String url) { + ConnectionUrl connectionUrl = new ConnectionUrl(url); + connectionUrl.parseUrl(); + return connectionUrl; + } + + private ConnectionUrl(final String url) { + this.url = Objects.requireNonNull(url, "Connection URL cannot be null"); + } + + public static boolean accepts(final String url) { + return url.startsWith("jdbc:tc:"); + } + + /** + * This method applies various REGEX Patterns to parse the URL associated with this instance. + * This is called from a @{@link ConnectionUrl#newInstance(String)} static factory method to create immutable instance of + * {@link ConnectionUrl}. + * To avoid mutation after class is instantiated, this method should not be publicly accessible. + */ + private void parseUrl() { + /* + Extract from the JDBC connection URL: + * The database type (e.g. mysql, postgresql, ...) + * The docker tag, if provided. + * The URL query string, if provided + */ + Matcher urlMatcher = Patterns.URL_MATCHING_PATTERN.matcher(this.getUrl()); + if (!urlMatcher.matches()) { + //Try for Oracle pattern + urlMatcher = Patterns.ORACLE_URL_MATCHING_PATTERN.matcher(this.getUrl()); + if (!urlMatcher.matches()) { + throw new IllegalArgumentException( + "JDBC URL matches jdbc:tc: prefix but the database or tag name could not be identified" + ); + } + } + databaseType = urlMatcher.group("databaseType"); + + imageTag = Optional.ofNullable(urlMatcher.group("imageTag")); + + //String like hostname:port/database name, which may vary based on target database. + //Clients can further parse it as needed. + dbHostString = urlMatcher.group("dbHostString"); + + //In case it matches to the default pattern + Matcher dbInstanceMatcher = Patterns.DB_INSTANCE_MATCHING_PATTERN.matcher(dbHostString); + if (dbInstanceMatcher.matches()) { + databaseHost = Optional.ofNullable(dbInstanceMatcher.group("databaseHost")); + databasePort = Optional.ofNullable(dbInstanceMatcher.group("databasePort")).map(Integer::valueOf); + databaseName = Optional.of(dbInstanceMatcher.group("databaseName")); + } + + queryParameters = + Collections.unmodifiableMap( + parseQueryParameters(Optional.ofNullable(urlMatcher.group("queryParameters")).orElse("")) + ); + + String query = queryParameters + .entrySet() + .stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining("&")); + + if (query.trim().length() == 0) { + queryString = Optional.empty(); + } else { + queryString = Optional.of("?" + query); + } + + containerParameters = Collections.unmodifiableMap(parseContainerParameters()); + + tmpfsOptions = parseTmpfsOptions(containerParameters); + + initScriptPath = Optional.ofNullable(containerParameters.get("TC_INITSCRIPT")); + + reusable = Boolean.parseBoolean(containerParameters.get("TC_REUSABLE")); + + Matcher funcMatcher = Patterns.INITFUNCTION_MATCHING_PATTERN.matcher(this.getUrl()); + if (funcMatcher.matches()) { + initFunction = Optional.of(new InitFunctionDef(funcMatcher.group(2), funcMatcher.group(4))); + } + + Matcher daemonMatcher = Patterns.DAEMON_MATCHING_PATTERN.matcher(this.getUrl()); + inDaemonMode = daemonMatcher.matches() && Boolean.parseBoolean(daemonMatcher.group(2)); + } + + private Map parseTmpfsOptions(Map containerParameters) { + if (!containerParameters.containsKey("TC_TMPFS")) { + return Collections.emptyMap(); + } + + String tmpfsOptions = containerParameters.get("TC_TMPFS"); + + return Stream + .of(tmpfsOptions.split(",")) + .collect(Collectors.toMap(string -> string.split(":")[0], string -> string.split(":")[1])); + } + + /** + * Get the Testcontainers Parameters such as Init Function, Init Script path etc. + * + * @return {@link Map} + */ + private Map parseContainerParameters() { + Map results = new HashMap<>(); + + Matcher matcher = Patterns.TC_PARAM_MATCHING_PATTERN.matcher(this.getUrl()); + while (matcher.find()) { + String key = matcher.group(1); + String value = matcher.group(2); + results.put(key, value); + } + + return results; + } + + /** + * Get all Query parameters specified in the Connection URL after ?. This DOES NOT include Testcontainers (TC_*) parameters. + * + * @return {@link Map} + */ + private Map parseQueryParameters(final String queryString) { + Map results = new HashMap<>(); + Matcher matcher = Patterns.QUERY_PARAM_MATCHING_PATTERN.matcher(queryString); + while (matcher.find()) { + String key = matcher.group(1); + String value = matcher.group(2); + if (!key.matches(Patterns.TC_PARAM_NAME_PATTERN)) { + results.put(key, value); + } + } + + return results; + } + + public Map getTmpfsOptions() { + return Collections.unmodifiableMap(tmpfsOptions); + } + + /** + * This interface defines the Regex Patterns used by {@link ConnectionUrl}. + */ + public interface Patterns { + Pattern URL_MATCHING_PATTERN = Pattern.compile( + "jdbc:tc:" + + "(?[a-z0-9]+)" + + "(:(?[^:]+))?" + + "://" + + "(?[^?]+)" + + "(?\\?.*)?" + ); + + Pattern ORACLE_URL_MATCHING_PATTERN = Pattern.compile( + "jdbc:tc:" + + "(?[a-z]+)" + + "(:(?(?!thin).+))?:thin:(//)?" + + "(" + + "(?[^:" + + "?^/]+)/(?[^?^/]+)" + + ")?" + + "@" + + "(?[^?]+)" + + "(?\\?.*)?" + ); + + //Matches to part of string - hostname:port/databasename + Pattern DB_INSTANCE_MATCHING_PATTERN = Pattern.compile( + "(?[^:]+)?" + + "(:(?[0-9]+))?" + + "(" + + "(?[:/])" + + "|" + + ";databaseName=" + + ")" + + "(?[^\\\\?]+)" + ); + + Pattern DAEMON_MATCHING_PATTERN = Pattern.compile(".*([?&]?)TC_DAEMON=([^?&]+).*"); + + /** + * @deprecated for removal + */ + @Deprecated + Pattern INITSCRIPT_MATCHING_PATTERN = Pattern.compile(".*([?&]?)TC_INITSCRIPT=([^?&]+).*"); + + Pattern INITFUNCTION_MATCHING_PATTERN = Pattern.compile( + ".*([?&]?)TC_INITFUNCTION=" + + "((\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*\\.)*\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)" + + "::" + + "(\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)" + + ".*" + ); + + String TC_PARAM_NAME_PATTERN = "(TC_[A-Z_]+)"; + + Pattern TC_PARAM_MATCHING_PATTERN = Pattern.compile(TC_PARAM_NAME_PATTERN + "=([^?&]+)"); + + Pattern QUERY_PARAM_MATCHING_PATTERN = Pattern.compile("([^?&=]+)=([^?&]*)"); + } + + @Getter + @AllArgsConstructor + public class InitFunctionDef { + + private String className; + + private String methodName; + } +} diff --git a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionWrapper.java b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionWrapper.java index 5a783c96947..4f245590f78 100644 --- a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionWrapper.java +++ b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionWrapper.java @@ -1,294 +1,24 @@ package org.testcontainers.jdbc; -import java.sql.*; -import java.util.Map; -import java.util.Properties; -import java.util.concurrent.Executor; +import java.sql.Connection; +import java.sql.SQLException; -/** - * @author richardnorth - */ -class ConnectionWrapper implements Connection { - private final Connection delegate; - private final Runnable closeCallback; - - public ConnectionWrapper(Connection delegate, Runnable closeCallback) { - this.delegate = delegate; - this.closeCallback = closeCallback; - } - - @Override - public Statement createStatement() throws SQLException { - return delegate.createStatement(); - } - - @Override - public PreparedStatement prepareStatement(String sql) throws SQLException { - return delegate.prepareStatement(sql); - } - - @Override - public CallableStatement prepareCall(String sql) throws SQLException { - return delegate.prepareCall(sql); - } - - @Override - public String nativeSQL(String sql) throws SQLException { - return delegate.nativeSQL(sql); - } - - @Override - public void setAutoCommit(boolean autoCommit) throws SQLException { - delegate.setAutoCommit(autoCommit); - } - - @Override - public boolean getAutoCommit() throws SQLException { - return delegate.getAutoCommit(); - } +public class ConnectionWrapper extends ConnectionDelegate { - @Override - public void commit() throws SQLException { - delegate.commit(); - } + private final Runnable closeCallback; - @Override - public void rollback() throws SQLException { - delegate.rollback(); + public ConnectionWrapper(Connection connection, Runnable runnable) { + super(connection); + this.closeCallback = runnable; } @Override public void close() throws SQLException { - delegate.close(); + super.close(); try { - this.closeCallback.run(); + closeCallback.run(); } catch (Exception e) { e.printStackTrace(); } } - - @Override - public boolean isClosed() throws SQLException { - return delegate.isClosed(); - } - - @Override - public DatabaseMetaData getMetaData() throws SQLException { - return delegate.getMetaData(); - } - - @Override - public void setReadOnly(boolean readOnly) throws SQLException { - delegate.setReadOnly(readOnly); - } - - @Override - public boolean isReadOnly() throws SQLException { - return delegate.isReadOnly(); - } - - @Override - public void setCatalog(String catalog) throws SQLException { - delegate.setCatalog(catalog); - } - - @Override - public String getCatalog() throws SQLException { - return delegate.getCatalog(); - } - - @Override - public void setTransactionIsolation(int level) throws SQLException { - delegate.setTransactionIsolation(level); - } - - @Override - public int getTransactionIsolation() throws SQLException { - return delegate.getTransactionIsolation(); - } - - @Override - public SQLWarning getWarnings() throws SQLException { - return delegate.getWarnings(); - } - - @Override - public void clearWarnings() throws SQLException { - delegate.clearWarnings(); - } - - @Override - public Statement createStatement(int resultSetType, int resultSetConcurrency) throws SQLException { - return delegate.createStatement(resultSetType, resultSetConcurrency); - } - - @Override - public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) throws SQLException { - return delegate.prepareStatement(sql, resultSetType, resultSetConcurrency); - } - - @Override - public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency) throws SQLException { - return delegate.prepareCall(sql, resultSetType, resultSetConcurrency); - } - - @Override - public Map> getTypeMap() throws SQLException { - return delegate.getTypeMap(); - } - - @Override - public void setTypeMap(Map> map) throws SQLException { - delegate.setTypeMap(map); - } - - @Override - public void setHoldability(int holdability) throws SQLException { - delegate.setHoldability(holdability); - } - - @Override - public int getHoldability() throws SQLException { - return delegate.getHoldability(); - } - - @Override - public Savepoint setSavepoint() throws SQLException { - return delegate.setSavepoint(); - } - - @Override - public Savepoint setSavepoint(String name) throws SQLException { - return delegate.setSavepoint(name); - } - - @Override - public void rollback(Savepoint savepoint) throws SQLException { - delegate.rollback(); - } - - @Override - public void releaseSavepoint(Savepoint savepoint) throws SQLException { - delegate.releaseSavepoint(savepoint); - } - - @Override - public Statement createStatement(int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException { - return delegate.createStatement(resultSetType, resultSetConcurrency, resultSetHoldability); - } - - @Override - public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException { - return delegate.prepareStatement(sql, resultSetType, resultSetConcurrency, resultSetHoldability); - } - - @Override - public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException { - return delegate.prepareCall(sql, resultSetType, resultSetConcurrency, resultSetHoldability); - } - - @Override - public PreparedStatement prepareStatement(String sql, int autoGeneratedKeys) throws SQLException { - return delegate.prepareStatement(sql, autoGeneratedKeys); - } - - @Override - public PreparedStatement prepareStatement(String sql, int[] columnIndexes) throws SQLException { - return delegate.prepareStatement(sql, columnIndexes); - } - - @Override - public PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException { - return delegate.prepareStatement(sql, columnNames); - } - - @Override - public Clob createClob() throws SQLException { - return delegate.createClob(); - } - - @Override - public Blob createBlob() throws SQLException { - return delegate.createBlob(); - } - - @Override - public NClob createNClob() throws SQLException { - return delegate.createNClob(); - } - - @Override - public SQLXML createSQLXML() throws SQLException { - return delegate.createSQLXML(); - } - - @Override - public boolean isValid(int timeout) throws SQLException { - return delegate.isValid(timeout); - } - - @Override - public void setClientInfo(String name, String value) throws SQLClientInfoException { - delegate.setClientInfo(name, value); - } - - @Override - public void setClientInfo(Properties properties) throws SQLClientInfoException { - delegate.setClientInfo(properties); - } - - @Override - public String getClientInfo(String name) throws SQLException { - return delegate.getClientInfo(name); - } - - @Override - public Properties getClientInfo() throws SQLException { - return delegate.getClientInfo(); - } - - @Override - public Array createArrayOf(String typeName, Object[] elements) throws SQLException { - return delegate.createArrayOf(typeName, elements); - } - - @Override - public Struct createStruct(String typeName, Object[] attributes) throws SQLException { - return delegate.createStruct(typeName, attributes); - } - - @Override - public void setSchema(String schema) throws SQLException { - delegate.setSchema(schema); - } - - @Override - public String getSchema() throws SQLException { - return delegate.getSchema(); - } - - @Override - public void abort(Executor executor) throws SQLException { - delegate.abort(executor); - } - - @Override - public void setNetworkTimeout(Executor executor, int milliseconds) throws SQLException { - delegate.setNetworkTimeout(executor, milliseconds); - } - - @Override - public int getNetworkTimeout() throws SQLException { - return delegate.getNetworkTimeout(); - } - - @Override - public T unwrap(Class iface) throws SQLException { - return delegate.unwrap(iface); - } - - @Override - public boolean isWrapperFor(Class iface) throws SQLException { - return delegate.isWrapperFor(iface); - } } diff --git a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ContainerDatabaseDriver.java b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ContainerDatabaseDriver.java index 937ca61536f..8f5f5f5a425 100644 --- a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ContainerDatabaseDriver.java +++ b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ContainerDatabaseDriver.java @@ -1,22 +1,32 @@ package org.testcontainers.jdbc; -import com.google.common.base.Charsets; -import com.google.common.io.Resources; +import org.apache.commons.io.IOUtils; import org.slf4j.LoggerFactory; import org.testcontainers.containers.JdbcDatabaseContainer; import org.testcontainers.containers.JdbcDatabaseContainerProvider; -import org.testcontainers.jdbc.ext.ScriptUtils; +import org.testcontainers.delegate.DatabaseDelegate; +import org.testcontainers.ext.ScriptUtils; -import javax.script.ScriptException; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.URL; -import java.sql.*; -import java.util.*; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.Driver; +import java.sql.DriverManager; +import java.sql.DriverPropertyInfo; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Properties; +import java.util.ServiceLoader; +import java.util.Set; import java.util.logging.Logger; -import java.util.regex.Matcher; -import java.util.regex.Pattern; + +import javax.script.ScriptException; /** * Test Containers JDBC proxy driver. This driver will handle JDBC URLs of the form: @@ -36,22 +46,18 @@ */ public class ContainerDatabaseDriver implements Driver { - private static final Pattern URL_MATCHING_PATTERN = Pattern.compile("jdbc:tc:([a-z]+)(:([^:]+))?://[^\\?]+(\\?.*)?"); - private static final Pattern INITSCRIPT_MATCHING_PATTERN = Pattern.compile(".*([\\?&]?)TC_INITSCRIPT=([^\\?&]+).*"); - private static final Pattern INITFUNCTION_MATCHING_PATTERN = Pattern.compile(".*([\\?&]?)TC_INITFUNCTION=" + - "((\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*\\.)*\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)" + - "::" + - "(\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)" + - ".*"); - - private static final Pattern TC_PARAM_MATCHING_PATTERN = Pattern.compile("([A-Z_]+)=([^\\?&]+)"); private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(ContainerDatabaseDriver.class); private Driver delegate; + private static final Map> containerConnections = new HashMap<>(); + private static final Map jdbcUrlContainerCache = new HashMap<>(); + private static final Set initializedContainers = new HashSet<>(); + private static final String FILE_PATH_PREFIX = "file:"; + static { load(); } @@ -71,7 +77,6 @@ public boolean acceptsURL(String url) throws SQLException { @Override public synchronized Connection connect(String url, final Properties info) throws SQLException { - /* The driver should return "null" if it realizes it is the wrong kind of driver to connect to the given URL. */ @@ -79,50 +84,37 @@ public synchronized Connection connect(String url, final Properties info) throws return null; } - synchronized (jdbcUrlContainerCache) { + ConnectionUrl connectionUrl = ConnectionUrl.newInstance(url); - String queryString = ""; + synchronized (jdbcUrlContainerCache) { + String queryString = connectionUrl.getQueryString().orElse(""); /* If we already have a running container for this exact connection string, we want to connect to that rather than create a new container */ - JdbcDatabaseContainer container = jdbcUrlContainerCache.get(url); + JdbcDatabaseContainer container = jdbcUrlContainerCache.get(connectionUrl.getUrl()); if (container == null) { - /* - Extract from the JDBC connection URL: - * The database type (e.g. mysql, postgresql, ...) - * The docker tag, if provided. - * The URL query string, if provided - */ - Matcher urlMatcher = URL_MATCHING_PATTERN.matcher(url); - if (!urlMatcher.matches()) { - throw new IllegalArgumentException("JDBC URL matches jdbc:tc: prefix but the database or tag name could not be identified"); - } - String databaseType = urlMatcher.group(1); - String tag = urlMatcher.group(3); - if (tag == null) { - tag = "latest"; - } - - queryString = urlMatcher.group(4); - if (queryString == null) { - queryString = ""; - } + LOGGER.debug("Container not found in cache, creating new instance"); - Map parameters = getContainerParameters(url); + Map parameters = connectionUrl.getContainerParameters(); /* Find a matching container type using ServiceLoader. */ - ServiceLoader databaseContainers = ServiceLoader.load(JdbcDatabaseContainerProvider.class); + ServiceLoader databaseContainers = ServiceLoader.load( + JdbcDatabaseContainerProvider.class + ); for (JdbcDatabaseContainerProvider candidateContainerType : databaseContainers) { - if (candidateContainerType.supports(databaseType)) { - container = candidateContainerType.newInstance(tag); + if (candidateContainerType.supports(connectionUrl.getDatabaseType())) { + container = candidateContainerType.newInstance(connectionUrl); + container.withTmpFs(connectionUrl.getTmpfsOptions()); delegate = container.getJdbcDriverInstance(); } } if (container == null) { - throw new UnsupportedOperationException("Database name " + databaseType + " not supported"); + throw new UnsupportedOperationException( + "Database name " + connectionUrl.getDatabaseType() + " not supported" + ); } /* @@ -145,83 +137,93 @@ public synchronized Connection connect(String url, final Properties info) throws /* Create a connection using the delegated driver. The container must be ready to accept connections. */ - Connection connection = container.createConnection(queryString); + Connection connection = container.createConnection(queryString, info); /* If this container has not been initialized, AND an init script or function has been specified, use it */ if (!initializedContainers.contains(container.getContainerId())) { - runInitScriptIfRequired(url, connection); - runInitFunctionIfRequired(url, connection); + DatabaseDelegate databaseDelegate = new JdbcDatabaseDelegate(container, queryString); + runInitScriptIfRequired(connectionUrl, databaseDelegate); + runInitFunctionIfRequired(connectionUrl, connection); initializedContainers.add(container.getContainerId()); } - return wrapConnection(connection, container, url); + return wrapConnection(connection, container, connectionUrl); } } - private Map getContainerParameters(String url) { - - Map results = new HashMap<>(); - - Matcher matcher = TC_PARAM_MATCHING_PATTERN.matcher(url); - while (matcher.find()) { - String key = matcher.group(1); - String value = matcher.group(2); - results.put(key, value); - } - - return results; - } - /** * Wrap the connection, setting up a callback to be called when the connection is closed. *

* When there are no more open connections, the container itself will be stopped. * - * @param connection the new connection to be wrapped - * @param container the container which the connection is associated with - * @param url the testcontainers JDBC URL for this connection + * @param connection the new connection to be wrapped + * @param container the container which the connection is associated with + * @param connectionUrl {@link ConnectionUrl} instance representing JDBC Url for this connection * @return the connection, wrapped */ - private Connection wrapConnection(final Connection connection, final JdbcDatabaseContainer container, final String url) { - Set connections = containerConnections.get(container.getContainerId()); - - if (connections == null) { - connections = new HashSet<>(); - containerConnections.put(container.getContainerId(), connections); - } + private Connection wrapConnection( + final Connection connection, + final JdbcDatabaseContainer container, + final ConnectionUrl connectionUrl + ) { + final boolean isDaemon = connectionUrl.isInDaemonMode() || connectionUrl.isReusable(); + + Set connections = containerConnections.computeIfAbsent( + container.getContainerId(), + k -> new HashSet<>() + ); connections.add(connection); final Set finalConnections = connections; - return new ConnectionWrapper(connection, () -> { - finalConnections.remove(connection); - if (finalConnections.isEmpty()) { - container.stop(); - jdbcUrlContainerCache.remove(url); + return new ConnectionWrapper( + connection, + () -> { + finalConnections.remove(connection); + if (!isDaemon && finalConnections.isEmpty()) { + synchronized (jdbcUrlContainerCache) { + container.stop(); + jdbcUrlContainerCache.remove(connectionUrl.getUrl()); + } + } } - }); + ); } /** * Run an init script from the classpath. * - * @param url the JDBC URL to check for init script declarations. - * @param connection JDBC connection to apply init scripts to. + * @param connectionUrl {@link ConnectionUrl} instance representing JDBC Url with init script. + * @param databaseDelegate database delegate to apply init scripts to the database * @throws SQLException on script or DB error */ - private void runInitScriptIfRequired(String url, Connection connection) throws SQLException { - Matcher matcher = INITSCRIPT_MATCHING_PATTERN.matcher(url); - if (matcher.matches()) { - String initScriptPath = matcher.group(2); + private void runInitScriptIfRequired(final ConnectionUrl connectionUrl, DatabaseDelegate databaseDelegate) + throws SQLException { + if (connectionUrl.getInitScriptPath().isPresent()) { + String initScriptPath = connectionUrl.getInitScriptPath().get(); try { - URL resource = Resources.getResource(initScriptPath); - String sql = Resources.toString(resource, Charsets.UTF_8); - ScriptUtils.executeSqlScript(connection, initScriptPath, sql); - } catch (IOException | IllegalArgumentException e) { + URL resource; + if (initScriptPath.startsWith(FILE_PATH_PREFIX)) { + //relative workdir path + resource = new URL(initScriptPath); + } else { + //classpath resource + resource = Thread.currentThread().getContextClassLoader().getResource(initScriptPath); + } + if (resource == null) { + LOGGER.warn("Could not load classpath init script: {}", initScriptPath); + throw new SQLException( + "Could not load classpath init script: " + initScriptPath + ". Resource not found." + ); + } + + String sql = IOUtils.toString(resource, StandardCharsets.UTF_8); + ScriptUtils.executeDatabaseScript(databaseDelegate, initScriptPath, sql); + } catch (IOException e) { LOGGER.warn("Could not load classpath init script: {}", initScriptPath); throw new SQLException("Could not load classpath init script: " + initScriptPath, e); } catch (ScriptException e) { @@ -234,22 +236,24 @@ private void runInitScriptIfRequired(String url, Connection connection) throws S /** * Run an init function (must be a public static method on an accessible class). * - * @param url the JDBC URL to check for init function declarations. - * @param connection JDBC connection to apply init functions to. + * @param connectionUrl {@link ConnectionUrl} instance representing JDBC Url with r init function declarations. + * @param connection JDBC connection to apply init functions to. * @throws SQLException on script or DB error */ - private void runInitFunctionIfRequired(String url, Connection connection) throws SQLException { - Matcher matcher = INITFUNCTION_MATCHING_PATTERN.matcher(url); - if (matcher.matches()) { - String className = matcher.group(2); - String methodName = matcher.group(4); + private void runInitFunctionIfRequired(final ConnectionUrl connectionUrl, Connection connection) + throws SQLException { + if (connectionUrl.getInitFunction().isPresent()) { + String className = connectionUrl.getInitFunction().get().getClassName(); + String methodName = connectionUrl.getInitFunction().get().getMethodName(); try { Class initFunctionClazz = Class.forName(className); Method method = initFunctionClazz.getMethod(methodName, Connection.class); method.invoke(null, connection); - } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + } catch ( + ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InvocationTargetException e + ) { LOGGER.error("Error while executing init function: {}::{}", className, methodName, e); throw new SQLException("Error while executing init function: " + className + "::" + methodName, e); } @@ -258,27 +262,30 @@ private void runInitFunctionIfRequired(String url, Connection connection) throws @Override public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) throws SQLException { - return delegate.getPropertyInfo(url, info); + return delegate != null ? delegate.getPropertyInfo(url, info) : new DriverPropertyInfo[0]; } @Override public int getMajorVersion() { - return delegate.getMajorVersion(); + return delegate != null ? delegate.getMajorVersion() : 1; } @Override public int getMinorVersion() { - return delegate.getMinorVersion(); + return delegate != null ? delegate.getMinorVersion() : 0; } @Override public boolean jdbcCompliant() { - return delegate.jdbcCompliant(); + return delegate != null && delegate.jdbcCompliant(); } @Override public Logger getParentLogger() throws SQLFeatureNotSupportedException { - return delegate.getParentLogger(); + if (delegate != null) { + return delegate.getParentLogger(); + } + throw new SQLFeatureNotSupportedException("getParentLogger not supported"); } /** @@ -293,13 +300,13 @@ public static void killContainers() { containerConnections.clear(); initializedContainers.clear(); } - } /** * Utility method to kill a database container directly from test support code. It shouldn't be necessary to use this, * but it is provided for convenience - e.g. for situations where many different database containers are being * tested and cleanup is needed to limit resource usage. + * * @param jdbcUrl the JDBC URL of the container which should be killed */ public static void killContainer(String jdbcUrl) { @@ -313,4 +320,16 @@ public static void killContainer(String jdbcUrl) { } } } + + /** + * Utility method to get an instance of a database container given its JDBC URL. + * + * @param jdbcUrl the JDBC URL of the container instance to get + * @return an instance of database container or null if no container associated with JDBC URL + */ + static JdbcDatabaseContainer getContainer(String jdbcUrl) { + synchronized (jdbcUrlContainerCache) { + return jdbcUrlContainerCache.get(jdbcUrl); + } + } } diff --git a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ContainerLessJdbcDelegate.java b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ContainerLessJdbcDelegate.java new file mode 100644 index 00000000000..0806ddd2c31 --- /dev/null +++ b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ContainerLessJdbcDelegate.java @@ -0,0 +1,36 @@ +package org.testcontainers.jdbc; + +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.exception.ConnectionCreationException; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; + +/** + * Containerless jdbc database delegate + * + * Is used only with deprecated ScriptUtils + * + * @see org.testcontainers.ext.ScriptUtils + */ +@Slf4j +public class ContainerLessJdbcDelegate extends JdbcDatabaseDelegate { + + private Connection connection; + + public ContainerLessJdbcDelegate(Connection connection) { + super(null, ""); + this.connection = connection; + } + + @Override + protected Statement createNewConnection() { + try { + return connection.createStatement(); + } catch (SQLException e) { + log.error("Could create JDBC statement"); + throw new ConnectionCreationException("Could create JDBC statement", e); + } + } +} diff --git a/modules/jdbc/src/main/java/org/testcontainers/jdbc/JdbcDatabaseDelegate.java b/modules/jdbc/src/main/java/org/testcontainers/jdbc/JdbcDatabaseDelegate.java new file mode 100644 index 00000000000..24c18944292 --- /dev/null +++ b/modules/jdbc/src/main/java/org/testcontainers/jdbc/JdbcDatabaseDelegate.java @@ -0,0 +1,77 @@ +package org.testcontainers.jdbc; + +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.delegate.AbstractDatabaseDelegate; +import org.testcontainers.exception.ConnectionCreationException; +import org.testcontainers.ext.ScriptUtils; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; + +/** + * JDBC database delegate + */ +@Slf4j +public class JdbcDatabaseDelegate extends AbstractDatabaseDelegate { + + private JdbcDatabaseContainer container; + + private Connection connection; + + private String queryString; + + public JdbcDatabaseDelegate(JdbcDatabaseContainer container, String queryString) { + this.container = container; + this.queryString = queryString; + } + + @Override + protected Statement createNewConnection() { + try { + connection = container.createConnection(queryString); + return connection.createStatement(); + } catch (SQLException e) { + log.error("Could not obtain JDBC connection"); + throw new ConnectionCreationException("Could not obtain JDBC connection", e); + } + } + + @Override + public void execute( + String statement, + String scriptPath, + int lineNumber, + boolean continueOnError, + boolean ignoreFailedDrops + ) { + try { + boolean rowsAffected = getConnection().execute(statement); + log.debug("{} returned as updateCount for SQL: {}", rowsAffected, statement); + } catch (SQLException ex) { + boolean dropStatement = statement.trim().toLowerCase().startsWith("drop"); + if (continueOnError || (dropStatement && ignoreFailedDrops)) { + log.debug( + "Failed to execute SQL script statement at line {} of resource {}: {}", + lineNumber, + scriptPath, + statement, + ex + ); + } else { + throw new ScriptUtils.ScriptStatementFailedException(statement, lineNumber, scriptPath, ex); + } + } + } + + @Override + protected void closeConnectionQuietly(Statement statement) { + try { + statement.close(); + connection.close(); + } catch (Exception e) { + log.error("Could not close JDBC connection", e); + } + } +} diff --git a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ext/ScriptUtils.java b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ext/ScriptUtils.java index 6533deb0008..c6a3c0fa3ed 100644 --- a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ext/ScriptUtils.java +++ b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ext/ScriptUtils.java @@ -16,311 +16,125 @@ package org.testcontainers.jdbc.ext; -import com.google.common.base.Preconditions; -import com.google.common.base.Strings; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.testcontainers.jdbc.ContainerLessJdbcDelegate; -import javax.script.ScriptException; import java.sql.Connection; -import java.sql.SQLException; -import java.sql.Statement; -import java.util.LinkedList; import java.util.List; +import javax.script.ScriptException; + /** - * This is a modified version of the Spring-JDBC ScriptUtils class, adapted to reduce - * dependencies and slightly alter the API. - * - * Generic utility methods for working with SQL scripts. Mainly for internal use - * within the framework. + * Wrapper for database-agnostic ScriptUtils * - * @author Thomas Risberg - * @author Sam Brannen - * @author Juergen Hoeller - * @author Keith Donald - * @author Dave Syer - * @author Chris Beams - * @author Oliver Gierke - * @author Chris Baldwin - * @since 4.0.3 + * @see org.testcontainers.ext.ScriptUtils + * @deprecated Needed only to keep binary compatibility for this internal API. Consider using database-agnostic ScriptUtils */ public abstract class ScriptUtils { - private static final Logger LOGGER = LoggerFactory.getLogger(ScriptUtils.class); - - /** - * Default statement separator within SQL scripts. - */ - public static final String DEFAULT_STATEMENT_SEPARATOR = ";"; - - /** - * Fallback statement separator within SQL scripts. - *

Used if neither a custom defined separator nor the - * {@link #DEFAULT_STATEMENT_SEPARATOR} is present in a given script. - */ - public static final String FALLBACK_STATEMENT_SEPARATOR = "\n"; - - /** - * Default prefix for line comments within SQL scripts. - */ - public static final String DEFAULT_COMMENT_PREFIX = "--"; - - /** - * Default start delimiter for block comments within SQL scripts. - */ - public static final String DEFAULT_BLOCK_COMMENT_START_DELIMITER = "/*"; - - /** - * Default end delimiter for block comments within SQL scripts. - */ - public static final String DEFAULT_BLOCK_COMMENT_END_DELIMITER = "*/"; - - - /** - * Prevent instantiation of this utility class. - */ - private ScriptUtils() { - /* no-op */ - } + /** + * Default statement separator within SQL scripts. + */ + public static final String DEFAULT_STATEMENT_SEPARATOR = ";"; /** - * Split an SQL script into separate statements delimited by the provided - * separator string. Each individual statement will be added to the provided - * {@code List}. - *

Within the script, the provided {@code commentPrefix} will be honored: - * any text beginning with the comment prefix and extending to the end of the - * line will be omitted from the output. Similarly, the provided - * {@code blockCommentStartDelimiter} and {@code blockCommentEndDelimiter} - * delimiters will be honored: any text enclosed in a block comment will be - * omitted from the output. In addition, multiple adjacent whitespace characters - * will be collapsed into a single space. - * @param resource the resource from which the script was read - * @param script the SQL script; never {@code null} or empty - * @param separator text separating each statement — typically a ';' or - * newline character; never {@code null} - * @param commentPrefix the prefix that identifies SQL line comments — - * typically "--"; never {@code null} or empty - * @param blockCommentStartDelimiter the start block comment delimiter; - * never {@code null} or empty - * @param blockCommentEndDelimiter the end block comment delimiter; - * never {@code null} or empty - * @param statements the list that will contain the individual statements - */ - public static void splitSqlScript(String resource, String script, String separator, String commentPrefix, - String blockCommentStartDelimiter, String blockCommentEndDelimiter, List statements) { + * Fallback statement separator within SQL scripts. + *

Used if neither a custom defined separator nor the + * {@link #DEFAULT_STATEMENT_SEPARATOR} is present in a given script. + */ + public static final String FALLBACK_STATEMENT_SEPARATOR = "\n"; - Preconditions.checkArgument(!Strings.isNullOrEmpty(script), "script must not be null or empty"); - Preconditions.checkArgument(separator != null, "separator must not be null"); - Preconditions.checkArgument(!Strings.isNullOrEmpty(commentPrefix), "commentPrefix must not be null or empty"); - Preconditions.checkArgument(!Strings.isNullOrEmpty(blockCommentStartDelimiter), "blockCommentStartDelimiter must not be null or empty"); - Preconditions.checkArgument(!Strings.isNullOrEmpty(blockCommentEndDelimiter), "blockCommentEndDelimiter must not be null or empty"); + /** + * Default prefix for line comments within SQL scripts. + */ + public static final String DEFAULT_COMMENT_PREFIX = "--"; - StringBuilder sb = new StringBuilder(); - boolean inLiteral = false; - boolean inEscape = false; - char[] content = script.toCharArray(); - for (int i = 0; i < script.length(); i++) { - char c = content[i]; - if (inEscape) { - inEscape = false; - sb.append(c); - continue; - } - // MySQL style escapes - if (c == '\\') { - inEscape = true; - sb.append(c); - continue; - } - if (c == '\'') { - inLiteral = !inLiteral; - } - if (!inLiteral) { - if (script.startsWith(separator, i)) { - // we've reached the end of the current statement - if (sb.length() > 0) { - statements.add(sb.toString()); - sb = new StringBuilder(); - } - i += separator.length() - 1; - continue; - } - else if (script.startsWith(commentPrefix, i)) { - // skip over any content from the start of the comment to the EOL - int indexOfNextNewline = script.indexOf("\n", i); - if (indexOfNextNewline > i) { - i = indexOfNextNewline; - continue; - } - else { - // if there's no EOL, we must be at the end - // of the script, so stop here. - break; - } - } - else if (script.startsWith(blockCommentStartDelimiter, i)) { - // skip over any block comments - int indexOfCommentEnd = script.indexOf(blockCommentEndDelimiter, i); - if (indexOfCommentEnd > i) { - i = indexOfCommentEnd + blockCommentEndDelimiter.length() - 1; - continue; - } - else { - throw new ScriptParseException(String.format("Missing block comment end delimiter [%s].", - blockCommentEndDelimiter), resource); - } - } - else if (c == ' ' || c == '\n' || c == '\t') { - // avoid multiple adjacent whitespace characters - if (sb.length() > 0 && sb.charAt(sb.length() - 1) != ' ') { - c = ' '; - } - else { - continue; - } - } - } - sb.append(c); - } - if (!Strings.isNullOrEmpty(sb.toString())) { - statements.add(sb.toString()); - } - } + /** + * Default start delimiter for block comments within SQL scripts. + */ + public static final String DEFAULT_BLOCK_COMMENT_START_DELIMITER = "/*"; /** - * Does the provided SQL script contain the specified delimiter? - * @param script the SQL script - * @param delim String delimiting each statement - typically a ';' character - */ - public static boolean containsSqlScriptDelimiters(String script, String delim) { - boolean inLiteral = false; - char[] content = script.toCharArray(); - for (int i = 0; i < script.length(); i++) { - if (content[i] == '\'') { - inLiteral = !inLiteral; - } - if (!inLiteral && script.startsWith(delim, i)) { - return true; - } - } - return false; - } + * Default end delimiter for block comments within SQL scripts. + */ + public static final String DEFAULT_BLOCK_COMMENT_END_DELIMITER = "*/"; - public static void executeSqlScript(Connection connection, String scriptPath, String script) throws ScriptException { - executeSqlScript(connection, scriptPath, script, false, false, DEFAULT_COMMENT_PREFIX, DEFAULT_STATEMENT_SEPARATOR, DEFAULT_BLOCK_COMMENT_START_DELIMITER, DEFAULT_BLOCK_COMMENT_END_DELIMITER); + /** + * Prevent instantiation of this utility class. + */ + private ScriptUtils() { + /* no-op */ } /** - * Execute the given SQL script. - *

Statement separators and comments will be removed before executing - * individual statements within the supplied script. - *

Do not use this method to execute DDL if you expect rollback. - * @param connection the JDBC connection to use to execute the script; already - * configured and ready to use - * @param scriptPath the resource (potentially associated with a specific encoding) - * to load the SQL script from - * @param script the raw script content - *@param continueOnError whether or not to continue without throwing an exception - * in the event of an error - * @param ignoreFailedDrops whether or not to continue in the event of specifically -* an error on a {@code DROP} statement - * @param commentPrefix the prefix that identifies comments in the SQL script — -* typically "--" - * @param separator the script statement separator; defaults to -* {@value #DEFAULT_STATEMENT_SEPARATOR} if not specified and falls back to -* {@value #FALLBACK_STATEMENT_SEPARATOR} as a last resort - * @param blockCommentStartDelimiter the start block comment delimiter; never -* {@code null} or empty - * @param blockCommentEndDelimiter the end block comment delimiter; never -* {@code null} or empty @throws ScriptException if an error occurred while executing the SQL script - */ - public static void executeSqlScript(Connection connection, String scriptPath, String script, boolean continueOnError, - boolean ignoreFailedDrops, String commentPrefix, String separator, String blockCommentStartDelimiter, - String blockCommentEndDelimiter) throws ScriptException { - - try { - if (LOGGER.isInfoEnabled()) { - LOGGER.info("Executing SQL script from " + script); - } - - long startTime = System.currentTimeMillis(); - List statements = new LinkedList<>(); - - if (separator == null) { - separator = DEFAULT_STATEMENT_SEPARATOR; - } - if (!containsSqlScriptDelimiters(script, separator)) { - separator = FALLBACK_STATEMENT_SEPARATOR; - } - - splitSqlScript(scriptPath, script, separator, commentPrefix, blockCommentStartDelimiter, - blockCommentEndDelimiter, statements); - int lineNumber = 0; - Statement stmt = connection.createStatement(); - try { - for (String statement : statements) { - lineNumber++; - try { - stmt.execute(statement); - int rowsAffected = stmt.getUpdateCount(); - if (LOGGER.isDebugEnabled()) { - LOGGER.debug(rowsAffected + " returned as updateCount for SQL: " + statement); - } - } - catch (SQLException ex) { - boolean dropStatement = statement.trim().toLowerCase().startsWith("drop"); - if (continueOnError || (dropStatement && ignoreFailedDrops)) { - if (LOGGER.isDebugEnabled()) { - LOGGER.debug("Failed to execute SQL script statement at line " + lineNumber - + " of resource " + scriptPath + ": " + statement, ex); - } - } - else { - throw new ScriptStatementFailedException(statement, lineNumber, scriptPath, ex); - } - } - } - } - finally { - try { - stmt.close(); - } - catch (Throwable ex) { - LOGGER.debug("Could not close JDBC Statement", ex); - } - } - - long elapsedTime = System.currentTimeMillis() - startTime; - if (LOGGER.isInfoEnabled()) { - LOGGER.info("Executed SQL script from " + scriptPath + " in " + elapsedTime + " ms."); - } - } - catch (Exception ex) { - if (ex instanceof ScriptException) { - throw (ScriptException) ex; - } - - throw new UncategorizedScriptException( - "Failed to execute database script from resource [" + script + "]", ex); - } - } + * @see org.testcontainers.ext.ScriptUtils + * @deprecated Needed only to keep binary compatibility for this internal API. Consider using database-agnostic ScriptUtils + */ + public static void splitSqlScript( + String resource, + String script, + String separator, + String commentPrefix, + String blockCommentStartDelimiter, + String blockCommentEndDelimiter, + List statements + ) { + org.testcontainers.ext.ScriptUtils.splitSqlScript( + resource, + script, + separator, + commentPrefix, + blockCommentStartDelimiter, + blockCommentEndDelimiter, + statements + ); + } - private static class ScriptParseException extends RuntimeException { - public ScriptParseException(String format, String scriptPath) { - super(String.format(format, scriptPath)); - } + /** + * @see org.testcontainers.ext.ScriptUtils + * @deprecated Needed only to keep binary compatibility for this internal API. Consider using database-agnostic ScriptUtils + */ + public static boolean containsSqlScriptDelimiters(String script, String delim) { + return org.testcontainers.ext.ScriptUtils.containsSqlScriptDelimiters(script, delim); } - private static class ScriptStatementFailedException extends RuntimeException { - public ScriptStatementFailedException(String statement, int lineNumber, String scriptPath, SQLException ex) { - super(String.format("Script execution failed (%s:%d): %s", scriptPath, lineNumber, statement), ex); - } + /** + * @see org.testcontainers.ext.ScriptUtils + * @deprecated Needed only to keep binary compatibility for this internal API. Consider using database-agnostic ScriptUtils + */ + public static void executeSqlScript(Connection connection, String scriptPath, String script) + throws ScriptException { + org.testcontainers.ext.ScriptUtils.executeDatabaseScript( + new ContainerLessJdbcDelegate(connection), + scriptPath, + script + ); } - private static class UncategorizedScriptException extends RuntimeException { - public UncategorizedScriptException(String s, Exception ex) { - super(s, ex); - } + /** + * @see org.testcontainers.ext.ScriptUtils + * @deprecated Needed only to keep binary compatibility for this internal API. Consider using database-agnostic ScriptUtils + */ + public static void executeSqlScript( + Connection connection, + String scriptPath, + String script, + boolean continueOnError, + boolean ignoreFailedDrops, + String commentPrefix, + String separator, + String blockCommentStartDelimiter, + String blockCommentEndDelimiter + ) throws ScriptException { + org.testcontainers.ext.ScriptUtils.executeDatabaseScript( + new ContainerLessJdbcDelegate(connection), + scriptPath, + script, + continueOnError, + ignoreFailedDrops, + commentPrefix, + separator, + blockCommentStartDelimiter, + blockCommentEndDelimiter + ); } } diff --git a/modules/jdbc/src/test/java/org/testcontainers/containers/JdbcDatabaseContainerTest.java b/modules/jdbc/src/test/java/org/testcontainers/containers/JdbcDatabaseContainerTest.java new file mode 100644 index 00000000000..ca41c3f5d1c --- /dev/null +++ b/modules/jdbc/src/test/java/org/testcontainers/containers/JdbcDatabaseContainerTest.java @@ -0,0 +1,72 @@ +package org.testcontainers.containers; + +import lombok.NonNull; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; + +import java.sql.Connection; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.mock; + +class JdbcDatabaseContainerTest { + + @Test + void anExceptionIsThrownIfJdbcIsNotAvailable() { + JdbcDatabaseContainer jdbcContainer = new JdbcDatabaseContainerStub("mysql:latest") + .withStartupTimeoutSeconds(1); + + assertThatExceptionOfType(IllegalStateException.class).isThrownBy(jdbcContainer::waitUntilContainerStarted); + } + + static class JdbcDatabaseContainerStub extends JdbcDatabaseContainer { + + public JdbcDatabaseContainerStub(@NonNull String dockerImageName) { + super(dockerImageName); + } + + @Override + public String getDriverClassName() { + return null; + } + + @Override + public String getJdbcUrl() { + return null; + } + + @Override + public String getUsername() { + return null; + } + + @Override + public String getPassword() { + return null; + } + + @Override + protected String getTestQueryString() { + return null; + } + + @Override + public boolean isRunning() { + return true; + } + + @Override + public Connection createConnection(String queryString) throws SQLException, NoDriverFoundException { + throw new SQLException("Could not create new connection"); + } + + @Override + protected Logger logger() { + return mock(Logger.class); + } + + @Override + public void setDockerImageName(@NonNull String dockerImageName) {} + } +} diff --git a/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlDriversTests.java b/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlDriversTests.java new file mode 100644 index 00000000000..5797ee0083c --- /dev/null +++ b/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlDriversTests.java @@ -0,0 +1,130 @@ +package org.testcontainers.jdbc; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Optional; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * This Test class validates that all supported JDBC URL's can be parsed by ConnectionUrl class. + */ +class ConnectionUrlDriversTests { + + public static Stream data() { + return Stream.of( + Arguments.arguments( + "jdbc:tc:mysql:8.0.36://hostname/test", + "mysql", + Optional.of("8.0.36"), + "hostname/test", + "test" + ), + Arguments.arguments("jdbc:tc:mysql://hostname/test", "mysql", Optional.empty(), "hostname/test", "test"), + Arguments.arguments( + "jdbc:tc:postgresql:1.2.3://hostname/test", + "postgresql", + Optional.of("1.2.3"), + "hostname/test", + "test" + ), + Arguments.arguments( + "jdbc:tc:postgresql://hostname/test", + "postgresql", + Optional.empty(), + "hostname/test", + "test" + ), + Arguments.arguments( + "jdbc:tc:sqlserver:1.2.3://localhost;instance=SQLEXPRESS:1433;databaseName=test", + "sqlserver", + Optional.of("1.2.3"), + "localhost;instance=SQLEXPRESS:1433;databaseName=test", + "test" + ), + Arguments.arguments( + "jdbc:tc:sqlserver://localhost;instance=SQLEXPRESS:1433;databaseName=test", + "sqlserver", + Optional.empty(), + "localhost;instance=SQLEXPRESS:1433;databaseName=test", + "test" + ), + Arguments.arguments( + "jdbc:tc:mariadb:1.2.3://localhost:3306/test", + "mariadb", + Optional.of("1.2.3"), + "localhost:3306/test", + "test" + ), + Arguments.arguments( + "jdbc:tc:mariadb://localhost:3306/test", + "mariadb", + Optional.empty(), + "localhost:3306/test", + "test" + ), + Arguments.arguments( + "jdbc:tc:oracle:1.2.3:thin://@localhost:1521/test", + "oracle", + Optional.of("1.2.3"), + "localhost:1521/test", + "test" + ), + Arguments.arguments( + "jdbc:tc:oracle:1.2.3:thin:@localhost:1521/test", + "oracle", + Optional.of("1.2.3"), + "localhost:1521/test", + "test" + ), + Arguments.arguments( + "jdbc:tc:oracle:thin:@localhost:1521/test", + "oracle", + Optional.empty(), + "localhost:1521/test", + "test" + ), + Arguments.arguments( + "jdbc:tc:oracle:1.2.3:thin:@localhost:1521:test", + "oracle", + Optional.of("1.2.3"), + "localhost:1521:test", + "test" + ), + Arguments.arguments( + "jdbc:tc:oracle:1.2.3:thin://@localhost:1521:test", + "oracle", + Optional.of("1.2.3"), + "localhost:1521:test", + "test" + ), + Arguments.arguments( + "jdbc:tc:oracle:1.2.3-anything:thin://@localhost:1521:test", + "oracle", + Optional.of("1.2.3-anything"), + "localhost:1521:test", + "test" + ), + Arguments.arguments( + "jdbc:tc:oracle:thin:@localhost:1521:test", + "oracle", + Optional.empty(), + "localhost:1521:test", + "test" + ) + ); + } + + @ParameterizedTest(name = "{index} - {0}") + @MethodSource("data") + void test(String jdbcUrl, String databaseType, Optional tag, String dbHostString, String databaseName) { + ConnectionUrl url = ConnectionUrl.newInstance(jdbcUrl); + assertThat(url.getDatabaseType()).as("Database Type is as expected").isEqualTo(databaseType); + assertThat(url.getImageTag()).as("Image tag is as expected").isEqualTo(tag); + assertThat(url.getDbHostString()).as("Database Host String is as expected").isEqualTo(dbHostString); + assertThat(url.getDatabaseName().orElse("")).as("Database Name is as expected").isEqualTo(databaseName); + } +} diff --git a/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlTest.java b/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlTest.java new file mode 100644 index 00000000000..16abff74ec9 --- /dev/null +++ b/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlTest.java @@ -0,0 +1,127 @@ +package org.testcontainers.jdbc; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ConnectionUrlTest { + + @Test + void testConnectionUrl1() { + String urlString = "jdbc:tc:mysql:8.0.36://somehostname:3306/databasename?a=b&c=d"; + ConnectionUrl url = ConnectionUrl.newInstance(urlString); + + assertThat(url.getDatabaseType()).as("Database Type value is as expected").isEqualTo("mysql"); + assertThat(url.getImageTag()).as("Database Image tag value is as expected").contains("8.0.36"); + assertThat(url.getDbHostString()) + .as("Database Host String is as expected") + .isEqualTo("somehostname:3306/databasename"); + assertThat(url.getQueryString()).as("Query String value is as expected").contains("?a=b&c=d"); + assertThat(url.getDatabaseHost()).as("Database Host value is as expected").contains("somehostname"); + assertThat(url.getDatabasePort()).as("Database Port value is as expected").contains(3306); + assertThat(url.getDatabaseName()).as("Database Name value is as expected").contains("databasename"); + + assertThat(url.getQueryParameters()).as("Parameter a is captured").containsEntry("a", "b"); + assertThat(url.getQueryParameters()).as("Parameter c is captured").containsEntry("c", "d"); + } + + @Test + void testConnectionUrl2() { + String urlString = "jdbc:tc:mysql://somehostname/databasename"; + ConnectionUrl url = ConnectionUrl.newInstance(urlString); + + assertThat(url.getDatabaseType()).as("Database Type value is as expected").isEqualTo("mysql"); + assertThat(url.getImageTag()).as("Database Image tag value is as expected").isNotPresent(); + assertThat(url.getDbHostString()) + .as("Database Host String is as expected") + .isEqualTo("somehostname/databasename"); + assertThat(url.getQueryString()).as("Query String value is as expected").isEmpty(); + assertThat(url.getDatabaseHost()).as("Database Host value is as expected").contains("somehostname"); + assertThat(url.getDatabasePort()).as("Database Port is null as expected").isNotPresent(); + assertThat(url.getDatabaseName()).as("Database Name value is as expected").contains("databasename"); + + assertThat(url.getQueryParameters().isEmpty()).as("Connection Parameters set is empty").isTrue(); + } + + @Test + void testEmptyQueryParameter() { + ConnectionUrl url = ConnectionUrl.newInstance("jdbc:tc:mysql://somehostname/databasename?key="); + assertThat(url.getQueryParameters().get("key")).as("'key' property value").isEqualTo(""); + } + + @Test + void testTmpfsOption() { + String urlString = "jdbc:tc:mysql://somehostname/databasename?TC_TMPFS=key:value,key1:value1"; + ConnectionUrl url = ConnectionUrl.newInstance(urlString); + + assertThat(url.getQueryParameters()).as("Connection Parameters set is empty").isEmpty(); + assertThat(url.getContainerParameters()).as("Container Parameters set is not empty").isNotEmpty(); + assertThat(url.getContainerParameters()) + .as("Container Parameter TC_TMPFS is true") + .containsEntry("TC_TMPFS", "key:value,key1:value1"); + assertThat(url.getTmpfsOptions()).as("tmpfs option key has correct value").containsEntry("key", "value"); + assertThat(url.getTmpfsOptions()).as("tmpfs option key1 has correct value").containsEntry("key1", "value1"); + } + + @Test + void testInitScriptPathCapture() { + String urlString = + "jdbc:tc:mysql:8.0.36://somehostname:3306/databasename?a=b&c=d&TC_INITSCRIPT=somepath/init_mysql.sql"; + ConnectionUrl url = ConnectionUrl.newInstance(urlString); + + assertThat(url.getInitScriptPath()) + .as("Database Type value is as expected") + .contains("somepath/init_mysql.sql"); + assertThat(url.getQueryString()).as("Query String value is as expected").contains("?a=b&c=d"); + assertThat(url.getContainerParameters()) + .as("INIT SCRIPT Path exists in Container Parameters") + .containsEntry("TC_INITSCRIPT", "somepath/init_mysql.sql"); + + //Parameter sets are unmodifiable + assertThatThrownBy(() -> url.getContainerParameters().remove("TC_INITSCRIPT")) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> url.getQueryParameters().remove("a")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void testInitFunctionCapture() { + String urlString = + "jdbc:tc:mysql:8.0.36://somehostname:3306/databasename?a=b&c=d&TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction"; + ConnectionUrl url = ConnectionUrl.newInstance(urlString); + + assertThat(url.getInitFunction()).as("Init Function parameter exists").isPresent(); + + assertThat(url.getInitFunction().get().getClassName()) + .as("Init function class is as expected") + .isEqualTo("org.testcontainers.jdbc.JDBCDriverTest"); + assertThat(url.getInitFunction().get().getMethodName()) + .as("Init function class is as expected") + .isEqualTo("sampleInitFunction"); + } + + @Test + void testDaemonCapture() { + String urlString = "jdbc:tc:mysql:8.0.36://somehostname:3306/databasename?a=b&c=d&TC_DAEMON=true"; + ConnectionUrl url = ConnectionUrl.newInstance(urlString); + + assertThat(url.isInDaemonMode()).as("Daemon flag is set to true.").isTrue(); + } + + @Test + void testHostLessConnectionUrl() { + String urlString = "jdbc:tc:mysql:8.0.36:///databasename?a=b&c=d"; + ConnectionUrl url = ConnectionUrl.newInstance(urlString); + + assertThat(url.getDatabaseType()).as("Database Type value is as expected").isEqualTo("mysql"); + assertThat(url.getImageTag()).as("Database Image tag value is as expected").contains("8.0.36"); + assertThat(url.getQueryString()).as("Query String value is as expected").contains("?a=b&c=d"); + assertThat(url.getDatabaseHost()).as("Database Host value is as expected").isEmpty(); + assertThat(url.getDatabasePort()).as("Database Port value is as expected").isEmpty(); + assertThat(url.getDatabaseName()).as("Database Name value is as expected").contains("databasename"); + + assertThat(url.getQueryParameters()).as("Parameter a is captured").containsEntry("a", "b"); + assertThat(url.getQueryParameters()).as("Parameter c is captured").containsEntry("c", "d"); + } +} diff --git a/modules/jdbc/src/test/java/org/testcontainers/jdbc/ContainerDatabaseDriverTest.java b/modules/jdbc/src/test/java/org/testcontainers/jdbc/ContainerDatabaseDriverTest.java index d33e399db27..767258e19a1 100644 --- a/modules/jdbc/src/test/java/org/testcontainers/jdbc/ContainerDatabaseDriverTest.java +++ b/modules/jdbc/src/test/java/org/testcontainers/jdbc/ContainerDatabaseDriverTest.java @@ -1,35 +1,30 @@ package org.testcontainers.jdbc; -import org.hamcrest.CoreMatchers; -import org.junit.Assert; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; +import org.junit.jupiter.api.Test; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import java.util.Properties; -public class ContainerDatabaseDriverTest { +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; - private static final String PLAIN_POSTGRESQL_JDBC_URL = "jdbc:postgresql://localhost:5432/test"; +class ContainerDatabaseDriverTest { - @Rule - public ExpectedException thrown = ExpectedException.none(); + private static final String PLAIN_POSTGRESQL_JDBC_URL = "jdbc:postgresql://localhost:5432/test"; - @Test - public void shouldNotTryToConnectToNonMatchingJdbcUrlDirectly() throws SQLException { - ContainerDatabaseDriver driver = new ContainerDatabaseDriver(); - Connection connection = driver.connect(PLAIN_POSTGRESQL_JDBC_URL, new Properties()); - Assert.assertNull(connection); - } - - @Test - public void shouldNotTryToConnectToNonMatchingJdbcUrlViaDriverManager() throws SQLException { - thrown.expect(SQLException.class); - thrown.expectMessage(CoreMatchers.startsWith("No suitable driver found for ")); - DriverManager.getConnection(PLAIN_POSTGRESQL_JDBC_URL); - } + @Test + void shouldNotTryToConnectToNonMatchingJdbcUrlDirectly() throws SQLException { + ContainerDatabaseDriver driver = new ContainerDatabaseDriver(); + Connection connection = driver.connect(PLAIN_POSTGRESQL_JDBC_URL, new Properties()); + assertThat(connection).isNull(); + } + @Test + void shouldNotTryToConnectToNonMatchingJdbcUrlViaDriverManager() throws SQLException { + assertThatThrownBy(() -> DriverManager.getConnection(PLAIN_POSTGRESQL_JDBC_URL)) + .isInstanceOf(SQLException.class) + .hasMessageStartingWith("No suitable driver found for "); + } } diff --git a/modules/jdbc/src/test/java/org/testcontainers/jdbc/JdbcDatabaseDelegateTest.java b/modules/jdbc/src/test/java/org/testcontainers/jdbc/JdbcDatabaseDelegateTest.java new file mode 100644 index 00000000000..e6233e14b9f --- /dev/null +++ b/modules/jdbc/src/test/java/org/testcontainers/jdbc/JdbcDatabaseDelegateTest.java @@ -0,0 +1,87 @@ +package org.testcontainers.jdbc; + +import lombok.NonNull; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.slf4j.Logger; +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.utility.DockerImageName; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class JdbcDatabaseDelegateTest { + + @Test + void testLeakedConnections() { + final JdbcDatabaseContainerStub stub = new JdbcDatabaseContainerStub(DockerImageName.parse("something")); + try (JdbcDatabaseDelegate delegate = new JdbcDatabaseDelegate(stub, "")) { + delegate.execute("foo", null, 0, false, false); + } + assertThat(stub.openConnectionsList.size()).isZero(); + } + + static class JdbcDatabaseContainerStub extends JdbcDatabaseContainer { + + List openConnectionsList = new ArrayList<>(); + + public JdbcDatabaseContainerStub(@NonNull DockerImageName dockerImageName) { + super(dockerImageName); + } + + @Override + public String getDriverClassName() { + return null; + } + + @Override + public String getJdbcUrl() { + return null; + } + + @Override + public String getUsername() { + return null; + } + + @Override + public String getPassword() { + return null; + } + + @Override + protected String getTestQueryString() { + return null; + } + + @Override + public boolean isRunning() { + return true; + } + + @Override + public Connection createConnection(String queryString) throws NoDriverFoundException, SQLException { + final Connection connection = mock(Connection.class); + openConnectionsList.add(connection); + when(connection.createStatement()).thenReturn(mock(Statement.class)); + connection.close(); + Mockito.doAnswer(ignore -> openConnectionsList.remove(connection)).when(connection).close(); + return connection; + } + + @Override + protected Logger logger() { + return mock(Logger.class); + } + + @Override + public void setDockerImageName(@NonNull String dockerImageName) {} + } +} diff --git a/modules/jdbc/src/test/java/org/testcontainers/jdbc/MissingJdbcDriverTest.java b/modules/jdbc/src/test/java/org/testcontainers/jdbc/MissingJdbcDriverTest.java new file mode 100644 index 00000000000..469234e15a6 --- /dev/null +++ b/modules/jdbc/src/test/java/org/testcontainers/jdbc/MissingJdbcDriverTest.java @@ -0,0 +1,90 @@ +package org.testcontainers.jdbc; + +import com.google.common.base.Throwables; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.utility.DockerImageName; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +class MissingJdbcDriverTest { + + @Test + void shouldFailFastIfNoDriverFound() { + final MissingDriverContainer container = new MissingDriverContainer(); + + try { + container.start(); + fail("The container is expected to fail to start"); + } catch (Exception e) { + final Throwable rootCause = Throwables.getRootCause(e); + assertThat(rootCause) + .as("ClassNotFoundException is the root cause") + .isInstanceOf(ClassNotFoundException.class); + } finally { + container.stop(); + } + + assertThat(container.getConnectionAttempts()) + .as("only one connection attempt should have been made") + .isEqualTo(1); + } + + /** + * Container class for the purposes of testing, with a known non-existent driver + */ + static class MissingDriverContainer extends JdbcDatabaseContainer { + + private final AtomicInteger connectionAttempts = new AtomicInteger(); + + MissingDriverContainer() { + super(DockerImageName.parse("mysql:8.0.36")); + withEnv("MYSQL_ROOT_PASSWORD", "test"); + withExposedPorts(3306); + } + + @Override + public String getDriverClassName() { + return "nonexistent.ClassName"; + } + + @Override + public String getJdbcUrl() { + return ""; + } + + @Override + public String getUsername() { + return "root"; + } + + @Override + public String getPassword() { + return "test"; + } + + @Override + protected String getTestQueryString() { + return ""; + } + + @Override + public Connection createConnection(String queryString) throws SQLException, NoDriverFoundException { + connectionAttempts.incrementAndGet(); // + return super.createConnection(queryString); + } + + /** + * test window + * @return how many times a connection was attempted + */ + int getConnectionAttempts() { + return connectionAttempts.get(); + } + } +} diff --git a/modules/jdbc/src/test/resources/logback-test.xml b/modules/jdbc/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/jdbc/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/junit-jupiter/build.gradle b/modules/junit-jupiter/build.gradle new file mode 100644 index 00000000000..b9ac700cea4 --- /dev/null +++ b/modules/junit-jupiter/build.gradle @@ -0,0 +1,19 @@ +description = "Testcontainers :: JUnit Jupiter Extension" + +dependencies { + api project(':testcontainers') + implementation platform('org.junit:junit-bom:5.14.3') + implementation 'org.junit.jupiter:junit-jupiter-api' + + testImplementation project(':testcontainers-mysql') + testImplementation project(':testcontainers-postgresql') + testImplementation 'com.zaxxer:HikariCP:7.0.2' + testImplementation 'redis.clients:jedis:7.5.3' + testImplementation 'org.apache.httpcomponents:httpclient:4.5.14' + testImplementation ('org.mockito:mockito-core:4.11.0') { + exclude(module: 'hamcrest-core') + } + + testRuntimeOnly 'org.postgresql:postgresql:42.7.12' + testRuntimeOnly 'com.mysql:mysql-connector-j:9.6.0' +} diff --git a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/Container.java b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/Container.java new file mode 100644 index 00000000000..a22c23e57a6 --- /dev/null +++ b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/Container.java @@ -0,0 +1,17 @@ +package org.testcontainers.junit.jupiter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * The {@code @Container} annotation is used in conjunction with the {@link Testcontainers} annotation + * to mark containers that should be managed by the Testcontainers extension. + * + * @see Testcontainers + */ +@Target({ ElementType.FIELD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface Container { +} diff --git a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/DockerAvailableDetector.java b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/DockerAvailableDetector.java new file mode 100644 index 00000000000..804568e7dfc --- /dev/null +++ b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/DockerAvailableDetector.java @@ -0,0 +1,15 @@ +package org.testcontainers.junit.jupiter; + +import org.testcontainers.DockerClientFactory; + +class DockerAvailableDetector { + + public boolean isDockerAvailable() { + try { + DockerClientFactory.instance().client(); + return true; + } catch (Throwable ex) { + return false; + } + } +} diff --git a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/EnabledIfDockerAvailable.java b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/EnabledIfDockerAvailable.java new file mode 100644 index 00000000000..6c78cbb7759 --- /dev/null +++ b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/EnabledIfDockerAvailable.java @@ -0,0 +1,19 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * {@code EnabledIfDockerAvailable} is a JUnit Jupiter extension to enable tests only if Docker is available. + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@ExtendWith(EnabledIfDockerAvailableCondition.class) +public @interface EnabledIfDockerAvailable { +} diff --git a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/EnabledIfDockerAvailableCondition.java b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/EnabledIfDockerAvailableCondition.java new file mode 100644 index 00000000000..6067a9b4f4e --- /dev/null +++ b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/EnabledIfDockerAvailableCondition.java @@ -0,0 +1,47 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionConfigurationException; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.platform.commons.support.AnnotationSupport; + +import java.util.Optional; + +class EnabledIfDockerAvailableCondition implements ExecutionCondition { + + private final DockerAvailableDetector dockerDetector = new DockerAvailableDetector(); + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + return findAnnotation(context) + .map(this::evaluate) + .orElseThrow(() -> new ExtensionConfigurationException("@EnabledIfDockerAvailable not found")); + } + + boolean isDockerAvailable() { + return this.dockerDetector.isDockerAvailable(); + } + + private ConditionEvaluationResult evaluate(EnabledIfDockerAvailable testcontainers) { + if (isDockerAvailable()) { + return ConditionEvaluationResult.enabled("Docker is available"); + } + return ConditionEvaluationResult.disabled("Docker is not available"); + } + + private Optional findAnnotation(ExtensionContext context) { + Optional current = Optional.of(context); + while (current.isPresent()) { + Optional enabledIfDockerAvailable = AnnotationSupport.findAnnotation( + current.get().getRequiredTestClass(), + EnabledIfDockerAvailable.class + ); + if (enabledIfDockerAvailable.isPresent()) { + return enabledIfDockerAvailable; + } + current = current.get().getParent(); + } + return Optional.empty(); + } +} diff --git a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/FilesystemFriendlyNameGenerator.java b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/FilesystemFriendlyNameGenerator.java new file mode 100644 index 00000000000..fe5ddcf32e0 --- /dev/null +++ b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/FilesystemFriendlyNameGenerator.java @@ -0,0 +1,23 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.extension.ExtensionContext; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +class FilesystemFriendlyNameGenerator { + + private static final String UNKNOWN_NAME = "unknown"; + + static String filesystemFriendlyNameOf(ExtensionContext context) { + String contextId = context.getUniqueId(); + try { + return (contextId == null || contextId.trim().isEmpty()) + ? UNKNOWN_NAME + : URLEncoder.encode(contextId, StandardCharsets.UTF_8.toString()); + } catch (UnsupportedEncodingException e) { + return UNKNOWN_NAME; + } + } +} diff --git a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/Testcontainers.java b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/Testcontainers.java new file mode 100644 index 00000000000..8f9915ab334 --- /dev/null +++ b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/Testcontainers.java @@ -0,0 +1,74 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * {@code @Testcontainers} is a JUnit Jupiter extension to activate automatic + * startup and stop of containers used in a test case. + * + *

The Testcontainers extension finds all fields that are annotated with + * {@link Container} and calls their container lifecycle methods. Containers + * declared as static fields will be shared between test methods. They will be + * started only once before any test method is executed and stopped after the + * last test method has executed. Containers declared as instance fields will + * be started and stopped for every test method.

+ * + *

The annotation {@code @Testcontainers} can be used on a superclass in + * the test hierarchy as well. All subclasses will automatically inherit + * support for the extension.

+ * + *

Note: This extension has only been tested with sequential + * test execution. Using it with parallel test execution is unsupported and + * may have unintended side effects.

+ * + *

Example:

+ * + *
+ * @Testcontainers
+ * class MyTestcontainersTests {
+ *
+ *     // will be shared between test methods
+ *     @Container
+ *     private static final MySQLContainer MY_SQL_CONTAINER = new MySQLContainer();
+ *
+ *     // will be started before and stopped after each test method
+ *     @Container
+ *     private PostgreSQLContainer postgresqlContainer = new PostgreSQLContainer()
+ *             .withDatabaseName("foo")
+ *             .withUsername("foo")
+ *             .withPassword("secret");
+ *
+ *     @Test
+ *     void test() {
+ *         assertTrue(MY_SQL_CONTAINER.isRunning());
+ *         assertTrue(postgresqlContainer.isRunning());
+ *     }
+ * }
+ * 
+ * + * @see Container + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(TestcontainersExtension.class) +@Inherited +public @interface Testcontainers { + /** + * Whether tests should be disabled (rather than failing) when Docker is not available. Defaults to + * {@code false}. + * @return if the tests should be disabled when Docker is not available + */ + boolean disabledWithoutDocker() default false; + + /** + * Whether containers should start in parallel. Defaults to {@code false}. + * @return if the containers should start in parallel + */ + boolean parallel() default false; +} diff --git a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainersExtension.java b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainersExtension.java new file mode 100644 index 00000000000..89adba6033f --- /dev/null +++ b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainersExtension.java @@ -0,0 +1,285 @@ +package org.testcontainers.junit.jupiter; + +import lombok.Getter; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionConfigurationException; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Namespace; +import org.junit.jupiter.api.extension.ExtensionContext.Store; +import org.junit.jupiter.api.extension.ExtensionContext.Store.CloseableResource; +import org.junit.platform.commons.support.AnnotationSupport; +import org.junit.platform.commons.support.HierarchyTraversalMode; +import org.junit.platform.commons.support.ModifierSupport; +import org.junit.platform.commons.support.ReflectionSupport; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.lifecycle.Startables; +import org.testcontainers.lifecycle.TestDescription; +import org.testcontainers.lifecycle.TestLifecycleAware; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class TestcontainersExtension + implements BeforeEachCallback, BeforeAllCallback, AfterEachCallback, AfterAllCallback, ExecutionCondition { + + private static final Namespace NAMESPACE = Namespace.create(TestcontainersExtension.class); + + private static final String SHARED_LIFECYCLE_AWARE_CONTAINERS = "sharedLifecycleAwareContainers"; + + private static final String LOCAL_LIFECYCLE_AWARE_CONTAINERS = "localLifecycleAwareContainers"; + + private final DockerAvailableDetector dockerDetector = new DockerAvailableDetector(); + + @Override + public void beforeAll(ExtensionContext context) { + Class testClass = context + .getTestClass() + .orElseThrow(() -> { + return new ExtensionConfigurationException("TestcontainersExtension is only supported for classes."); + }); + + Store store = context.getStore(NAMESPACE); + List sharedContainersStoreAdapters = findSharedContainers(testClass); + + startContainers(sharedContainersStoreAdapters, store, context); + + List lifecycleAwareContainers = sharedContainersStoreAdapters + .stream() + .filter(this::isTestLifecycleAware) + .map(lifecycleAwareAdapter -> (TestLifecycleAware) lifecycleAwareAdapter.container) + .collect(Collectors.toList()); + + store.put(SHARED_LIFECYCLE_AWARE_CONTAINERS, lifecycleAwareContainers); + signalBeforeTestToContainers(lifecycleAwareContainers, testDescriptionFrom(context)); + } + + private void startContainers(List storeAdapters, Store store, ExtensionContext context) { + if (storeAdapters.isEmpty()) { + return; + } + + if (isParallelExecutionEnabled(context)) { + Stream startables = storeAdapters + .stream() + .map(storeAdapter -> { + store.getOrComputeIfAbsent(storeAdapter.getKey(), k -> storeAdapter); + return storeAdapter.container; + }); + Startables.deepStart(startables).join(); + } else { + storeAdapters.forEach(adapter -> store.getOrComputeIfAbsent(adapter.getKey(), k -> adapter.start())); + } + } + + @Override + public void afterAll(ExtensionContext context) { + signalAfterTestToContainersFor(SHARED_LIFECYCLE_AWARE_CONTAINERS, context); + } + + @Override + public void beforeEach(final ExtensionContext context) { + Store store = context.getStore(NAMESPACE); + + List restartContainers = collectParentTestInstances(context) + .parallelStream() + .flatMap(this::findRestartContainers) + .collect(Collectors.toList()); + + List lifecycleAwareContainers = findTestLifecycleAwareContainers( + restartContainers, + store, + context + ); + + store.put(LOCAL_LIFECYCLE_AWARE_CONTAINERS, lifecycleAwareContainers); + signalBeforeTestToContainers(lifecycleAwareContainers, testDescriptionFrom(context)); + } + + private List findTestLifecycleAwareContainers( + List restartContainers, + Store store, + ExtensionContext context + ) { + startContainers(restartContainers, store, context); + + return restartContainers + .stream() + .filter(this::isTestLifecycleAware) + .map(lifecycleAwareAdapter -> (TestLifecycleAware) lifecycleAwareAdapter.container) + .collect(Collectors.toList()); + } + + private boolean isParallelExecutionEnabled(ExtensionContext context) { + return findTestcontainers(context).map(Testcontainers::parallel).orElse(false); + } + + @Override + public void afterEach(ExtensionContext context) { + signalAfterTestToContainersFor(LOCAL_LIFECYCLE_AWARE_CONTAINERS, context); + } + + private void signalBeforeTestToContainers( + List lifecycleAwareContainers, + TestDescription testDescription + ) { + lifecycleAwareContainers.forEach(container -> container.beforeTest(testDescription)); + } + + private void signalAfterTestToContainersFor(String storeKey, ExtensionContext context) { + List lifecycleAwareContainers = (List) context + .getStore(NAMESPACE) + .get(storeKey); + if (lifecycleAwareContainers != null) { + TestDescription description = testDescriptionFrom(context); + Optional throwable = context.getExecutionException(); + lifecycleAwareContainers.forEach(container -> container.afterTest(description, throwable)); + } + } + + private TestDescription testDescriptionFrom(ExtensionContext context) { + return new TestcontainersTestDescription( + context.getUniqueId(), + FilesystemFriendlyNameGenerator.filesystemFriendlyNameOf(context) + ); + } + + private boolean isTestLifecycleAware(StoreAdapter adapter) { + return adapter.container instanceof TestLifecycleAware; + } + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + return findTestcontainers(context) + .map(this::evaluate) + .orElseThrow(() -> new ExtensionConfigurationException("@Testcontainers not found")); + } + + private Optional findTestcontainers(ExtensionContext context) { + Optional current = Optional.of(context); + while (current.isPresent()) { + Optional testcontainers = AnnotationSupport.findAnnotation( + current.get().getRequiredTestClass(), + Testcontainers.class + ); + if (testcontainers.isPresent()) { + return testcontainers; + } + current = current.get().getParent(); + } + return Optional.empty(); + } + + private ConditionEvaluationResult evaluate(Testcontainers testcontainers) { + if (testcontainers.disabledWithoutDocker()) { + if (isDockerAvailable()) { + return ConditionEvaluationResult.enabled("Docker is available"); + } + return ConditionEvaluationResult.disabled("disabledWithoutDocker is true and Docker is not available"); + } + return ConditionEvaluationResult.enabled("disabledWithoutDocker is false"); + } + + boolean isDockerAvailable() { + return this.dockerDetector.isDockerAvailable(); + } + + private Set collectParentTestInstances(final ExtensionContext context) { + List allInstances = new ArrayList<>(context.getRequiredTestInstances().getAllInstances()); + Collections.reverse(allInstances); + return new LinkedHashSet<>(allInstances); + } + + private List findSharedContainers(Class testClass) { + return ReflectionSupport + .findFields(testClass, isSharedContainer(), HierarchyTraversalMode.TOP_DOWN) + .stream() + .map(f -> getContainerInstance(null, f)) + .collect(Collectors.toList()); + } + + private Predicate isSharedContainer() { + return isContainer().and(ModifierSupport::isStatic); + } + + private Stream findRestartContainers(Object testInstance) { + return ReflectionSupport + .findFields(testInstance.getClass(), isRestartContainer(), HierarchyTraversalMode.TOP_DOWN) + .stream() + .map(f -> getContainerInstance(testInstance, f)); + } + + private Predicate isRestartContainer() { + return isContainer().and(ModifierSupport::isNotStatic); + } + + private static Predicate isContainer() { + return field -> { + boolean isAnnotatedWithContainer = AnnotationSupport.isAnnotated(field, Container.class); + if (isAnnotatedWithContainer) { + boolean isStartable = Startable.class.isAssignableFrom(field.getType()); + + if (!isStartable) { + throw new ExtensionConfigurationException( + String.format("FieldName: %s does not implement Startable", field.getName()) + ); + } + return true; + } + return false; + }; + } + + private static StoreAdapter getContainerInstance(final Object testInstance, final Field field) { + try { + field.setAccessible(true); + Startable containerInstance = (Startable) field.get(testInstance); + if (containerInstance == null) { + throw new ExtensionConfigurationException("Container " + field.getName() + " needs to be initialized"); + } + return new StoreAdapter(field.getDeclaringClass(), field.getName(), containerInstance); + } catch (IllegalAccessException e) { + throw new ExtensionConfigurationException("Can not access container defined in field " + field.getName()); + } + } + + /** + * An adapter for {@link Startable} that implement {@link CloseableResource} + * thereby letting the JUnit automatically stop containers once the current + * {@link ExtensionContext} is closed. + */ + private static class StoreAdapter implements CloseableResource, AutoCloseable { + + @Getter + private String key; + + private Startable container; + + private StoreAdapter(Class declaringClass, String fieldName, Startable container) { + this.key = declaringClass.getName() + "." + fieldName; + this.container = container; + } + + private StoreAdapter start() { + container.start(); + return this; + } + + @Override + public void close() { + container.stop(); + } + } +} diff --git a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainersTestDescription.java b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainersTestDescription.java new file mode 100644 index 00000000000..3cf466a5708 --- /dev/null +++ b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainersTestDescription.java @@ -0,0 +1,12 @@ +package org.testcontainers.junit.jupiter; + +import lombok.Value; +import org.testcontainers.lifecycle.TestDescription; + +@Value +class TestcontainersTestDescription implements TestDescription { + + String testId; + + String filesystemFriendlyName; +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/ComposeContainerTests.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/ComposeContainerTests.java new file mode 100644 index 00000000000..a60b24a23d2 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/ComposeContainerTests.java @@ -0,0 +1,33 @@ +package org.testcontainers.junit.jupiter; + +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.HttpClientBuilder; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +import java.io.File; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +class ComposeContainerTests { + + @Container + private ComposeContainer composeContainer = new ComposeContainer(new File("src/test/resources/docker-compose.yml")) + .withExposedService("whoami-1", 80, Wait.forHttp("/")); + + @Test + void running_compose_defined_container_is_accessible_on_configured_port() throws Exception { + HttpClient client = HttpClientBuilder.create().build(); + + String host = composeContainer.getServiceHost("whoami-1", 80); + int port = composeContainer.getServicePort("whoami-1", 80); + + HttpResponse response = client.execute(new HttpGet("http://" + host + ":" + port)); + + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/DockerComposeContainerTests.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/DockerComposeContainerTests.java new file mode 100644 index 00000000000..460d50a856b --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/DockerComposeContainerTests.java @@ -0,0 +1,37 @@ +package org.testcontainers.junit.jupiter; + +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.HttpClientBuilder; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +class DockerComposeContainerTests { + + @Container + private DockerComposeContainer composeContainer = new DockerComposeContainer( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/docker-compose.yml") + ) + .withExposedService("whoami_1", 80, Wait.forHttp("/")); + + @Test + void running_compose_defined_container_is_accessible_on_configured_port() throws Exception { + HttpClient client = HttpClientBuilder.create().build(); + + String host = composeContainer.getServiceHost("whoami_1", 80); + int port = composeContainer.getServicePort("whoami_1", 80); + + HttpResponse response = client.execute(new HttpGet("http://" + host + ":" + port)); + + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/EnabledIfDockerAvailableTests.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/EnabledIfDockerAvailableTests.java new file mode 100644 index 00000000000..455e94788f9 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/EnabledIfDockerAvailableTests.java @@ -0,0 +1,48 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExtensionContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class EnabledIfDockerAvailableTests { + + @Test + void whenDockerIsAvailableTestsAreEnabled() { + ConditionEvaluationResult result = new TestEnabledIfDockerAvailableCondition(true) + .evaluateExecutionCondition(extensionContext(DisabledWithoutDocker.class)); + assertThat(result.isDisabled()).isFalse(); + } + + @Test + void whenDockerIsUnavailableTestsAreDisabled() { + ConditionEvaluationResult result = new TestEnabledIfDockerAvailableCondition(false) + .evaluateExecutionCondition(extensionContext(DisabledWithoutDocker.class)); + assertThat(result.isDisabled()).isTrue(); + } + + private ExtensionContext extensionContext(Class clazz) { + ExtensionContext extensionContext = mock(ExtensionContext.class); + when(extensionContext.getRequiredTestClass()).thenReturn(clazz); + return extensionContext; + } + + @EnabledIfDockerAvailable + static final class DisabledWithoutDocker {} + + static final class TestEnabledIfDockerAvailableCondition extends EnabledIfDockerAvailableCondition { + + private final boolean dockerAvailable; + + private TestEnabledIfDockerAvailableCondition(boolean dockerAvailable) { + this.dockerAvailable = dockerAvailable; + } + + boolean isDockerAvailable() { + return dockerAvailable; + } + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/FilesystemFriendlyNameGeneratorTest.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/FilesystemFriendlyNameGeneratorTest.java new file mode 100644 index 00000000000..9050df6f257 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/FilesystemFriendlyNameGeneratorTest.java @@ -0,0 +1,43 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +class FilesystemFriendlyNameGeneratorTest { + + @ParameterizedTest + @MethodSource("provideDisplayNamesAndFilesystemFriendlyNames") + void should_generate_filesystem_friendly_name(String displayName, String expectedName) { + ExtensionContext context = mock(ExtensionContext.class); + doReturn(displayName).when(context).getUniqueId(); + + String filesystemFriendlyName = FilesystemFriendlyNameGenerator.filesystemFriendlyNameOf(context); + + assertThat(filesystemFriendlyName).isEqualTo(expectedName); + } + + private static Stream provideDisplayNamesAndFilesystemFriendlyNames() { + return Stream.of( + Arguments.of("", "unknown"), + Arguments.of(" ", "unknown"), + Arguments.of("not blank", "not+blank"), + Arguments.of("abc ABC 1234567890", "abc+ABC+1234567890"), + Arguments.of( + "no_umlauts_äöüÄÖÜéáíó", + "no_umlauts_%C3%A4%C3%B6%C3%BC%C3%84%C3%96%C3%9C%C3%A9%C3%A1%C3%AD%C3%B3" + ), + Arguments.of( + "[engine:junit-jupiter]/[class:com.example.MyTest]/[test-factory:parameterizedTest()]/[dynamic-test:#3]", + "%5Bengine%3Ajunit-jupiter%5D%2F%5Bclass%3Acom.example.MyTest%5D%2F%5Btest-factory%3AparameterizedTest%28%29%5D%2F%5Bdynamic-test%3A%233%5D" + ) + ); + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/JUnitJupiterTestImages.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/JUnitJupiterTestImages.java new file mode 100644 index 00000000000..4343b5dffc8 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/JUnitJupiterTestImages.java @@ -0,0 +1,11 @@ +package org.testcontainers.junit.jupiter; + +import org.testcontainers.utility.DockerImageName; + +public interface JUnitJupiterTestImages { + DockerImageName POSTGRES_IMAGE = DockerImageName.parse("postgres:9.6.12"); + + DockerImageName HTTPD_IMAGE = DockerImageName.parse("httpd:2.4-alpine"); + + DockerImageName MYSQL_IMAGE = DockerImageName.parse("mysql:8.0.32"); +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/MetaAnnotationTest.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/MetaAnnotationTest.java new file mode 100644 index 00000000000..627d778a93f --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/MetaAnnotationTest.java @@ -0,0 +1,28 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +class MetaAnnotationTest { + + @TcContainer + private static final PostgreSQLContainer POSTGRESQL = new PostgreSQLContainer<>( + JUnitJupiterTestImages.POSTGRES_IMAGE + ); + + @Test + void test() { + assertThat(POSTGRESQL.isRunning()).isTrue(); + } +} + +@Container +@Retention(RetentionPolicy.RUNTIME) +@interface TcContainer { +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/MixedLifecycleTests.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/MixedLifecycleTests.java new file mode 100644 index 00000000000..7dcd769bf08 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/MixedLifecycleTests.java @@ -0,0 +1,30 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.containers.PostgreSQLContainer; + +import static org.assertj.core.api.Assertions.assertThat; + +// testClass { +@Testcontainers +class MixedLifecycleTests { + + // will be shared between test methods + @Container + private static final MySQLContainer MY_SQL_CONTAINER = new MySQLContainer("mysql:8.0.36"); + + // will be started before and stopped after each test method + @Container + private PostgreSQLContainer postgresqlContainer = new PostgreSQLContainer("postgres:9.6.12") + .withDatabaseName("foo") + .withUsername("foo") + .withPassword("secret"); + + @Test + void test() { + assertThat(MY_SQL_CONTAINER.isRunning()).isTrue(); + assertThat(postgresqlContainer.isRunning()).isTrue(); + } +} +// } diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/ParallelExecutionTests.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/ParallelExecutionTests.java new file mode 100644 index 00000000000..84d11a8235b --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/ParallelExecutionTests.java @@ -0,0 +1,28 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.containers.PostgreSQLContainer; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers(parallel = true) +public class ParallelExecutionTests { + + @Container + private static final PostgreSQLContainer POSTGRESQL_CONTAINER = new PostgreSQLContainer<>( + JUnitJupiterTestImages.POSTGRES_IMAGE + ) + .withDatabaseName("foo") + .withUsername("foo") + .withPassword("secret"); + + @Container + private MySQLContainer mySQLContainer = new MySQLContainer<>(JUnitJupiterTestImages.MYSQL_IMAGE); + + @Test + void test() { + assertThat(POSTGRESQL_CONTAINER.isRunning()).isTrue(); + assertThat(mySQLContainer.isRunning()).isTrue(); + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/PostgresContainerTests.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/PostgresContainerTests.java new file mode 100644 index 00000000000..ee6d1f1a841 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/PostgresContainerTests.java @@ -0,0 +1,41 @@ +package org.testcontainers.junit.jupiter; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; + +import java.sql.ResultSet; +import java.sql.Statement; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +class PostgresContainerTests { + + @Container + private static final PostgreSQLContainer POSTGRE_SQL_CONTAINER = new PostgreSQLContainer<>( + JUnitJupiterTestImages.POSTGRES_IMAGE + ) + .withDatabaseName("foo") + .withUsername("foo") + .withPassword("secret"); + + @Test + void waits_until_postgres_accepts_jdbc_connections() throws Exception { + HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(POSTGRE_SQL_CONTAINER.getJdbcUrl()); + hikariConfig.setUsername("foo"); + hikariConfig.setPassword("secret"); + + try (HikariDataSource ds = new HikariDataSource(hikariConfig)) { + Statement statement = ds.getConnection().createStatement(); + statement.execute("SELECT 1"); + ResultSet resultSet = statement.getResultSet(); + resultSet.next(); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).isEqualTo(1); + } + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestLifecycleAwareContainerMock.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestLifecycleAwareContainerMock.java new file mode 100644 index 00000000000..a8217d60840 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestLifecycleAwareContainerMock.java @@ -0,0 +1,52 @@ +package org.testcontainers.junit.jupiter; + +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.lifecycle.TestDescription; +import org.testcontainers.lifecycle.TestLifecycleAware; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class TestLifecycleAwareContainerMock implements Startable, TestLifecycleAware { + + static final String BEFORE_TEST = "beforeTest"; + + static final String AFTER_TEST = "afterTest"; + + private final List lifecycleMethodCalls = new ArrayList<>(); + + private final List lifecycleFilesystemFriendlyNames = new ArrayList<>(); + + private Throwable capturedThrowable; + + @Override + public void beforeTest(TestDescription description) { + lifecycleMethodCalls.add(BEFORE_TEST); + lifecycleFilesystemFriendlyNames.add(description.getFilesystemFriendlyName()); + } + + @Override + public void afterTest(TestDescription description, Optional throwable) { + lifecycleMethodCalls.add(AFTER_TEST); + throwable.ifPresent(capturedThrowable -> this.capturedThrowable = capturedThrowable); + } + + List getLifecycleMethodCalls() { + return lifecycleMethodCalls; + } + + Throwable getCapturedThrowable() { + return capturedThrowable; + } + + public List getLifecycleFilesystemFriendlyNames() { + return lifecycleFilesystemFriendlyNames; + } + + @Override + public void start() {} + + @Override + public void stop() {} +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestLifecycleAwareExceptionCapturingTest.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestLifecycleAwareExceptionCapturingTest.java new file mode 100644 index 00000000000..a6647aa3e52 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestLifecycleAwareExceptionCapturingTest.java @@ -0,0 +1,37 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.opentest4j.TestAbortedException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +// The order of @ExtendsWith and @Testcontainers is crucial in order for the tests +@Testcontainers +@TestMethodOrder(OrderAnnotation.class) +class TestLifecycleAwareExceptionCapturingTest { + + @Container + private final TestLifecycleAwareContainerMock testContainer = new TestLifecycleAwareContainerMock(); + + private static TestLifecycleAwareContainerMock startedTestContainer; + + @Test + @Order(1) + void failing_test_should_pass_throwable_to_testContainer() { + startedTestContainer = testContainer; + // Force an exception that is captured by the test container without failing the test itself + assumeThat(false).isTrue(); + } + + @Test + @Order(2) + void should_have_captured_thrownException() { + Throwable capturedThrowable = startedTestContainer.getCapturedThrowable(); + assertThat(capturedThrowable).isInstanceOf(TestAbortedException.class); + assertThat(capturedThrowable.getMessage()).contains("Expecting value to be true but was false"); + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestLifecycleAwareMethodTest.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestLifecycleAwareMethodTest.java new file mode 100644 index 00000000000..a77242e1d3a --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestLifecycleAwareMethodTest.java @@ -0,0 +1,76 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ExtensionContext; + +import static org.assertj.core.api.Assertions.assertThat; + +// The order of @ExtendsWith and @Testcontainers is crucial for the tests +@ExtendWith({ TestLifecycleAwareMethodTest.SharedContainerAfterAllTestExtension.class }) +@Testcontainers +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class TestLifecycleAwareMethodTest { + + @Container + private final TestLifecycleAwareContainerMock testContainer = new TestLifecycleAwareContainerMock(); + + @Container + private static final TestLifecycleAwareContainerMock SHARED_CONTAINER = new TestLifecycleAwareContainerMock(); + + private static TestLifecycleAwareContainerMock startedTestContainer; + + @BeforeAll + static void beforeAll() { + assertThat(SHARED_CONTAINER.getLifecycleMethodCalls()) + .containsExactly(TestLifecycleAwareContainerMock.BEFORE_TEST); + } + + @Test + @Order(1) + void should_prepare_before_and_after_test() { + // we can only test for a call to afterTest() after this test has been finished. + startedTestContainer = testContainer; + } + + @Test + @Order(2) + void should_call_beforeTest_first_afterTest_later_with_filesystem_friendly_name() { + assertThat(startedTestContainer.getLifecycleMethodCalls()) + .containsExactly(TestLifecycleAwareContainerMock.BEFORE_TEST, TestLifecycleAwareContainerMock.AFTER_TEST); + } + + @Test + void should_have_a_filesystem_friendly_name_container_has_started() { + assertThat(startedTestContainer.getLifecycleFilesystemFriendlyNames()) + .containsExactly( + "%5Bengine%3Ajunit-jupiter%5D%2F%5Bclass%3Aorg.testcontainers.junit.jupiter.TestLifecycleAwareMethodTest%5D%2F%5Bmethod%3Ashould_prepare_before_and_after_test%28%29%5D" + ); + } + + @Test + void static_container_should_have_a_filesystem_friendly_name_after_container_has_started() { + assertThat(SHARED_CONTAINER.getLifecycleFilesystemFriendlyNames()) + .containsExactly( + "%5Bengine%3Ajunit-jupiter%5D%2F%5Bclass%3Aorg.testcontainers.junit.jupiter.TestLifecycleAwareMethodTest%5D" + ); + } + + static class SharedContainerAfterAllTestExtension implements AfterAllCallback { + + // Unfortunately it's not possible to write a @Test that is run after all tests + @Override + public void afterAll(ExtensionContext context) { + assertThat(SHARED_CONTAINER.getLifecycleMethodCalls()) + .containsExactly( + TestLifecycleAwareContainerMock.BEFORE_TEST, + TestLifecycleAwareContainerMock.AFTER_TEST + ); + } + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersExtensionTests.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersExtensionTests.java new file mode 100644 index 00000000000..8852524d451 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersExtensionTests.java @@ -0,0 +1,65 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExtensionContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TestcontainersExtensionTests { + + @Test + void whenDisabledWithoutDockerAndDockerIsAvailableTestsAreEnabled() { + ConditionEvaluationResult result = new TestTestcontainersExtension(true) + .evaluateExecutionCondition(extensionContext(DisabledWithoutDocker.class)); + assertThat(result.isDisabled()).isFalse(); + } + + @Test + void whenDisabledWithoutDockerAndDockerIsUnavailableTestsAreDisabled() { + ConditionEvaluationResult result = new TestTestcontainersExtension(false) + .evaluateExecutionCondition(extensionContext(DisabledWithoutDocker.class)); + assertThat(result.isDisabled()).isTrue(); + } + + @Test + void whenEnabledWithoutDockerAndDockerIsAvailableTestsAreEnabled() { + ConditionEvaluationResult result = new TestTestcontainersExtension(true) + .evaluateExecutionCondition(extensionContext(EnabledWithoutDocker.class)); + assertThat(result.isDisabled()).isFalse(); + } + + @Test + void whenEnabledWithoutDockerAndDockerIsUnavailableTestsAreEnabled() { + ConditionEvaluationResult result = new TestTestcontainersExtension(false) + .evaluateExecutionCondition(extensionContext(EnabledWithoutDocker.class)); + assertThat(result.isDisabled()).isFalse(); + } + + private ExtensionContext extensionContext(Class clazz) { + ExtensionContext extensionContext = mock(ExtensionContext.class); + when(extensionContext.getRequiredTestClass()).thenReturn(clazz); + return extensionContext; + } + + @Testcontainers(disabledWithoutDocker = true) + static final class DisabledWithoutDocker {} + + @Testcontainers + static final class EnabledWithoutDocker {} + + static final class TestTestcontainersExtension extends TestcontainersExtension { + + private final boolean dockerAvailable; + + private TestTestcontainersExtension(boolean dockerAvailable) { + this.dockerAvailable = dockerAvailable; + } + + boolean isDockerAvailable() { + return dockerAvailable; + } + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersNestedRestartedContainerTests.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersNestedRestartedContainerTests.java new file mode 100644 index 00000000000..f3d81a0dfcd --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersNestedRestartedContainerTests.java @@ -0,0 +1,79 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; + +import static org.assertj.core.api.Assertions.assertThat; + +// testClass { +@Testcontainers +class TestcontainersNestedRestartedContainerTests { + + @Container + private final GenericContainer topLevelContainer = new GenericContainer<>(JUnitJupiterTestImages.HTTPD_IMAGE) + .withExposedPorts(80); + + // }} + + private static String topLevelContainerId; + + private static String nestedContainerId; + + // testClass { + @Test + void top_level_container_should_be_running() { + assertThat(topLevelContainer.isRunning()).isTrue(); + // }} + topLevelContainerId = topLevelContainer.getContainerId(); + // testClass {{ + } + + @Nested + class NestedTestCase { + + @Container + private final GenericContainer nestedContainer = new GenericContainer<>(JUnitJupiterTestImages.HTTPD_IMAGE) + .withExposedPorts(80); + + @Test + void both_containers_should_be_running() { + // top level container is restarted for nested methods + assertThat(topLevelContainer.isRunning()).isTrue(); + // nested containers are only available inside their nested class + assertThat(nestedContainer.isRunning()).isTrue(); + // }}} + if (nestedContainerId == null) { + nestedContainerId = nestedContainer.getContainerId(); + } else { + assertThat(nestedContainer.getContainerId()).isNotEqualTo(nestedContainerId); + } + // testClass {{ + } + + // } + @Test + void containers_should_not_be_the_same() { + assertThat(nestedContainer.getContainerId()).isNotEqualTo(topLevelContainer.getContainerId()); + + if (nestedContainerId == null) { + nestedContainerId = nestedContainer.getContainerId(); + } else { + assertThat(nestedContainer.getContainerId()).isNotEqualTo(nestedContainerId); + } + } + + @Test + void ids_should_not_change() { + assertThat(topLevelContainer.getContainerId()).isNotEqualTo(topLevelContainerId); + + if (nestedContainerId == null) { + nestedContainerId = nestedContainer.getContainerId(); + } else { + assertThat(nestedContainer.getContainerId()).isNotEqualTo(nestedContainerId); + } + } + // testClass {{{ + } +} +// } diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersNestedSharedContainerTests.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersNestedSharedContainerTests.java new file mode 100644 index 00000000000..61a4df2c6ec --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersNestedSharedContainerTests.java @@ -0,0 +1,39 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +class TestcontainersNestedSharedContainerTests { + + @Container + private static final GenericContainer TOP_LEVEL_CONTAINER = new GenericContainer<>( + JUnitJupiterTestImages.HTTPD_IMAGE + ) + .withExposedPorts(80); + + private static String topLevelContainerId; + + @Test + void top_level_container_should_be_running() { + assertThat(TOP_LEVEL_CONTAINER.isRunning()).isTrue(); + topLevelContainerId = TOP_LEVEL_CONTAINER.getContainerId(); + } + + @Nested + class NestedTestCase { + + @Test + void top_level_containers_should_be_running() { + assertThat(TOP_LEVEL_CONTAINER.isRunning()).isTrue(); + } + + @Test + void ids_should_not_change() { + assertThat(TOP_LEVEL_CONTAINER.getContainerId()).isEqualTo(topLevelContainerId); + } + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersRestartBetweenTests.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersRestartBetweenTests.java new file mode 100644 index 00000000000..53e6a0968a0 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersRestartBetweenTests.java @@ -0,0 +1,34 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +class TestcontainersRestartBetweenTests { + + @Container + private GenericContainer genericContainer = new GenericContainer<>(JUnitJupiterTestImages.HTTPD_IMAGE) + .withExposedPorts(80); + + private static String lastContainerId; + + @Test + void first_test() { + if (lastContainerId == null) { + lastContainerId = genericContainer.getContainerId(); + } else { + assertThat(genericContainer.getContainerId()).isNotEqualTo(lastContainerId); + } + } + + @Test + void second_test() { + if (lastContainerId == null) { + lastContainerId = genericContainer.getContainerId(); + } else { + assertThat(genericContainer.getContainerId()).isNotEqualTo(lastContainerId); + } + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersSharedContainerTests.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersSharedContainerTests.java new file mode 100644 index 00000000000..9bd23e8d20e --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersSharedContainerTests.java @@ -0,0 +1,42 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +class TestcontainersSharedContainerTests { + + @Container + private static final GenericContainer GENERIC_CONTAINER = new GenericContainer<>( + JUnitJupiterTestImages.HTTPD_IMAGE + ) + .withExposedPorts(80); + + private static String lastContainerId; + + @BeforeAll + static void doSomethingWithAContainer() { + assertThat(GENERIC_CONTAINER.isRunning()).isTrue(); + } + + @Test + void first_test() { + if (lastContainerId == null) { + lastContainerId = GENERIC_CONTAINER.getContainerId(); + } else { + assertThat(GENERIC_CONTAINER.getContainerId()).isEqualTo(lastContainerId); + } + } + + @Test + void second_test() { + if (lastContainerId == null) { + lastContainerId = GENERIC_CONTAINER.getContainerId(); + } else { + assertThat(GENERIC_CONTAINER.getContainerId()).isEqualTo(lastContainerId); + } + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/WrongAnnotationUsageTests.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/WrongAnnotationUsageTests.java new file mode 100644 index 00000000000..9fe9893d9ee --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/WrongAnnotationUsageTests.java @@ -0,0 +1,17 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +@Disabled +@Testcontainers +class WrongAnnotationUsageTests { + + @Container + private String notStartable = "foobar"; + + @Test + void extension_throws_exception() { + assert true; + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/inheritance/AbstractTestBase.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/inheritance/AbstractTestBase.java new file mode 100644 index 00000000000..70614d3dbc7 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/inheritance/AbstractTestBase.java @@ -0,0 +1,14 @@ +package org.testcontainers.junit.jupiter.inheritance; + +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +abstract class AbstractTestBase { + + @Container + static RedisContainer redisPerClass = new RedisContainer(); + + @Container + RedisContainer redisPerTest = new RedisContainer(); +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/inheritance/InheritedTests.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/inheritance/InheritedTests.java new file mode 100644 index 00000000000..f57fde6b419 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/inheritance/InheritedTests.java @@ -0,0 +1,26 @@ +package org.testcontainers.junit.jupiter.inheritance; + +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; + +import static org.assertj.core.api.Assertions.assertThat; + +class InheritedTests extends AbstractTestBase { + + @Container + private RedisContainer myRedis = new RedisContainer(); + + @Test + void step1() { + assertThat(redisPerClass.getJedis().incr("key")).isEqualTo(1); + assertThat(redisPerTest.getJedis().incr("key")).isEqualTo(1); + assertThat(myRedis.getJedis().incr("key")).isEqualTo(1); + } + + @Test + void step2() { + assertThat(redisPerClass.getJedis().incr("key")).isEqualTo(2); + assertThat(redisPerTest.getJedis().incr("key")).isEqualTo(1); + assertThat(myRedis.getJedis().incr("key")).isEqualTo(1); + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/inheritance/RedisContainer.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/inheritance/RedisContainer.java new file mode 100644 index 00000000000..f6ed39c483e --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/inheritance/RedisContainer.java @@ -0,0 +1,17 @@ +package org.testcontainers.junit.jupiter.inheritance; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; +import redis.clients.jedis.Jedis; + +public class RedisContainer extends GenericContainer { + + public RedisContainer() { + super(DockerImageName.parse("redis:6-alpine")); + withExposedPorts(6379); + } + + public Jedis getJedis() { + return new Jedis(getHost(), getMappedPort(6379)); + } +} diff --git a/modules/junit-jupiter/src/test/resources/docker-compose.yml b/modules/junit-jupiter/src/test/resources/docker-compose.yml new file mode 100644 index 00000000000..810c7c178b5 --- /dev/null +++ b/modules/junit-jupiter/src/test/resources/docker-compose.yml @@ -0,0 +1,4 @@ +version: '2.1' +services: + whoami: + image: emilevauge/whoami diff --git a/modules/junit-jupiter/src/test/resources/logback-test.xml b/modules/junit-jupiter/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/junit-jupiter/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/k3s/build.gradle b/modules/k3s/build.gradle new file mode 100644 index 00000000000..0a8d628b469 --- /dev/null +++ b/modules/k3s/build.gradle @@ -0,0 +1,11 @@ +description = "Testcontainers :: K3S" + +dependencies { + api project(":testcontainers") + + // Synchronize with the jackson version, must match major and minor version + shaded 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.4' + + testImplementation 'io.fabric8:kubernetes-client:7.8.0' + testImplementation 'io.kubernetes:client-java:25.0.0-legacy' +} diff --git a/modules/k3s/src/main/java/org/testcontainers/k3s/K3sContainer.java b/modules/k3s/src/main/java/org/testcontainers/k3s/K3sContainer.java new file mode 100644 index 00000000000..6dd6f06d85e --- /dev/null +++ b/modules/k3s/src/main/java/org/testcontainers/k3s/K3sContainer.java @@ -0,0 +1,106 @@ +package org.testcontainers.k3s; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.github.dockerjava.api.command.InspectContainerResponse; +import lombok.SneakyThrows; +import org.apache.commons.io.IOUtils; +import org.testcontainers.containers.BindMode; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * Testcontainers implementation for K3S + *

+ * Supported image: {@code rancher/k3s} + */ +public class K3sContainer extends GenericContainer { + + public static int KUBE_SECURE_PORT = 6443; + + public static int RANCHER_WEBHOOK_PORT = 8443; + + private String kubeConfigYaml; + + public K3sContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DockerImageName.parse("rancher/k3s")); + + addExposedPorts(KUBE_SECURE_PORT, RANCHER_WEBHOOK_PORT); + setPrivilegedMode(true); + withCreateContainerCmdModifier(it -> { + it.getHostConfig().withCgroupnsMode("host"); + }); + addFileSystemBind("/sys/fs/cgroup", "/sys/fs/cgroup", BindMode.READ_WRITE); + + Map tmpFsMapping = new HashMap<>(); + tmpFsMapping.put("/run", ""); + tmpFsMapping.put("/var/run", ""); + setTmpFsMapping(tmpFsMapping); + + setCommand("server", "--disable=traefik", "--tls-san=" + this.getHost()); + setWaitStrategy(Wait.forLogMessage(".*Node controller sync successful.*", 1)); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + String rawKubeConfig = copyFileFromContainer( + "/etc/rancher/k3s/k3s.yaml", + is -> IOUtils.toString(is, StandardCharsets.UTF_8) + ); + String serverUrl = "https://" + this.getHost() + ":" + this.getMappedPort(KUBE_SECURE_PORT); + kubeConfigYaml = kubeConfigWithServerUrl(rawKubeConfig, serverUrl); + } + + /** + * Return the kubernetes client configuration to access k3s from the host machine. + * + * @return the kubeConfig yaml. + */ + public String getKubeConfigYaml() { + return kubeConfigYaml; + } + + /** + * Generate a kubernetes client configuration for use on a docker internal network. The kubeConfig can be used by + * another docker container running in the same network as the k3s container. For access from the host, use + * the {@link #getKubeConfigYaml()} method instead. + * + * @param networkAlias a valid network alias of the k3s container. + * @return the kubeConfig yaml. + */ + public String generateInternalKubeConfigYaml(String networkAlias) { + if (this.getNetworkAliases().contains(networkAlias)) { + String serverUrl = "https://" + networkAlias + ":" + KUBE_SECURE_PORT; + return kubeConfigWithServerUrl(kubeConfigYaml, serverUrl); + } else { + throw new IllegalArgumentException(networkAlias + " is not a network alias for k3s container"); + } + } + + @SneakyThrows + private String kubeConfigWithServerUrl(String kubeConfigYaml, String serverUrl) { + ObjectMapper objectMapper = new ObjectMapper(new YAMLFactory()); + + ObjectNode kubeConfigObjectNode = objectMapper.readValue(kubeConfigYaml, ObjectNode.class); + + JsonNode clusterNode = kubeConfigObjectNode.at("/clusters/0/cluster"); + if (!clusterNode.isObject()) { + throw new IllegalStateException("'/clusters/0/cluster' expected to be an object"); + } + ObjectNode clusterConfig = (ObjectNode) clusterNode; + clusterConfig.replace("server", new TextNode(serverUrl)); + + kubeConfigObjectNode.set("current-context", new TextNode("default")); + + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(kubeConfigObjectNode); + } +} diff --git a/modules/k3s/src/test/java/org/testcontainers/k3s/Fabric8K3sContainerTest.java b/modules/k3s/src/test/java/org/testcontainers/k3s/Fabric8K3sContainerTest.java new file mode 100644 index 00000000000..50b2e17f1fc --- /dev/null +++ b/modules/k3s/src/test/java/org/testcontainers/k3s/Fabric8K3sContainerTest.java @@ -0,0 +1,83 @@ +package org.testcontainers.k3s; + +import io.fabric8.kubernetes.api.model.ContainerBuilder; +import io.fabric8.kubernetes.api.model.ContainerPortBuilder; +import io.fabric8.kubernetes.api.model.Node; +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.api.model.PodBuilder; +import io.fabric8.kubernetes.api.model.PodSpec; +import io.fabric8.kubernetes.api.model.PodSpecBuilder; +import io.fabric8.kubernetes.api.model.ProbeBuilder; +import io.fabric8.kubernetes.client.Config; +import io.fabric8.kubernetes.client.DefaultKubernetesClient; +import io.fabric8.kubernetes.client.dsl.Resource; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.utility.DockerImageName; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@Slf4j +class Fabric8K3sContainerTest { + + @Test + void shouldStartAndHaveListableNode() { + try ( + // starting_k3s { + K3sContainer k3s = new K3sContainer(DockerImageName.parse("rancher/k3s:v1.21.3-k3s1")) + .withLogConsumer(new Slf4jLogConsumer(log)) + // } + ) { + k3s.start(); + + // connecting_with_fabric8 { + // obtain a kubeconfig file which allows us to connect to k3s + String kubeConfigYaml = k3s.getKubeConfigYaml(); + + // requires io.fabric8:kubernetes-client:5.11.0 or higher + Config config = Config.fromKubeconfig(kubeConfigYaml); + + DefaultKubernetesClient client = new DefaultKubernetesClient(config); + + // interact with the running K3s server, e.g.: + List nodes = client.nodes().list().getItems(); + // } + + assertThat(nodes).hasSize(1); + + // verify that we can start a pod + Pod helloworld = dummyStartablePod(); + client.pods().create(helloworld); + client.pods().inNamespace("default").withName("helloworld").waitUntilReady(30, TimeUnit.SECONDS); + + assertThat(client.pods().inNamespace("default").withName("helloworld")) + .extracting(Resource::isReady) + .isEqualTo(true); + } + } + + private Pod dummyStartablePod() { + PodSpec podSpec = new PodSpecBuilder() + .withContainers( + new ContainerBuilder() + .withName("helloworld") + .withImage("testcontainers/helloworld:1.1.0") + .withPorts(new ContainerPortBuilder().withContainerPort(8080).build()) + .withReadinessProbe(new ProbeBuilder().withNewTcpSocket().withNewPort(8080).endTcpSocket().build()) + .build() + ) + .build(); + + return new PodBuilder() + .withNewMetadata() + .withName("helloworld") + .withNamespace("default") + .endMetadata() + .withSpec(podSpec) + .build(); + } +} diff --git a/modules/k3s/src/test/java/org/testcontainers/k3s/KubectlContainerTest.java b/modules/k3s/src/test/java/org/testcontainers/k3s/KubectlContainerTest.java new file mode 100644 index 00000000000..6dcd7421363 --- /dev/null +++ b/modules/k3s/src/test/java/org/testcontainers/k3s/KubectlContainerTest.java @@ -0,0 +1,57 @@ +package org.testcontainers.k3s; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class KubectlContainerTest { + + private static final Network network = Network.SHARED; + + private static final K3sContainer k3s = new K3sContainer(DockerImageName.parse("rancher/k3s:v1.21.3-k3s1")) + .withNetwork(network) + .withNetworkAliases("k3s"); + + @BeforeAll + static void setup() { + k3s.start(); + } + + @AfterAll + static void teardown() { + k3s.stop(); + } + + @Test + public void shouldExposeKubeConfigForNetworkAlias() throws Exception { + String kubeConfigYaml = k3s.generateInternalKubeConfigYaml("k3s"); + + try ( + GenericContainer kubectlContainer = new GenericContainer<>("rancher/kubectl:v1.23.3") + .withNetwork(network) + .withCopyToContainer(Transferable.of(kubeConfigYaml), "/.kube/config") + .withCommand("get namespaces") + .withStartupCheckStrategy(new OneShotStartupCheckStrategy().withTimeout(Duration.ofSeconds(30))) + ) { + kubectlContainer.start(); + + assertThat(kubectlContainer.getLogs()).contains("kube-system"); + } + } + + @Test + public void shouldThrowAnExceptionForUnknownNetworkAlias() { + assertThatThrownBy(() -> k3s.generateInternalKubeConfigYaml("not-set-network-alias")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/modules/k3s/src/test/java/org/testcontainers/k3s/OfficialClientK3sContainerTest.java b/modules/k3s/src/test/java/org/testcontainers/k3s/OfficialClientK3sContainerTest.java new file mode 100644 index 00000000000..f71cbae558b --- /dev/null +++ b/modules/k3s/src/test/java/org/testcontainers/k3s/OfficialClientK3sContainerTest.java @@ -0,0 +1,48 @@ +package org.testcontainers.k3s; + +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1NodeList; +import io.kubernetes.client.util.Config; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.io.StringReader; + +import static org.assertj.core.api.Assertions.assertThat; + +@Slf4j +class OfficialClientK3sContainerTest { + + @Test + void shouldStartAndHaveListableNode() throws IOException, ApiException { + runK3s(DockerImageName.parse("rancher/k3s:v1.21.3-k3s1")); + } + + @Test + void shouldStartAndHaveListableNodeUsingLowerVersion() throws IOException, ApiException { + runK3s(DockerImageName.parse("rancher/k3s:v1.20.15-k3s1")); + } + + private void runK3s(DockerImageName k3sDockerImage) throws IOException, ApiException { + try (K3sContainer k3s = new K3sContainer(k3sDockerImage).withLogConsumer(new Slf4jLogConsumer(log))) { + k3s.start(); + + // connecting_with_k8sio { + String kubeConfigYaml = k3s.getKubeConfigYaml(); + + ApiClient client = Config.fromConfig(new StringReader(kubeConfigYaml)); + CoreV1Api api = new CoreV1Api(client); + + // interact with the running K3s server, e.g.: + V1NodeList nodes = api.listNode(null, null, null, null, null, null, null, null, null, null, null); + // } + + assertThat(nodes.getItems()).hasSize(1); + } + } +} diff --git a/modules/k3s/src/test/resources/logback-test.xml b/modules/k3s/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/k3s/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/k6/build.gradle b/modules/k6/build.gradle new file mode 100644 index 00000000000..0ca874ddc25 --- /dev/null +++ b/modules/k6/build.gradle @@ -0,0 +1,5 @@ +description = "Testcontainers :: k6" + +dependencies { + api project(':testcontainers') +} diff --git a/modules/k6/src/main/java/org/testcontainers/k6/K6Container.java b/modules/k6/src/main/java/org/testcontainers/k6/K6Container.java new file mode 100644 index 00000000000..04403ccb4cb --- /dev/null +++ b/modules/k6/src/main/java/org/testcontainers/k6/K6Container.java @@ -0,0 +1,88 @@ +package org.testcontainers.k6; + +import org.apache.commons.io.FilenameUtils; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class K6Container extends GenericContainer { + + /** Standard image for k6, as provided by Grafana. */ + private static final DockerImageName K6_IMAGE = DockerImageName.parse("grafana/k6"); + + private String testScript; + + private List cmdOptions = new ArrayList<>(); + + private Map scriptVars = new HashMap<>(); + + /** + * Creates a new container instance based upon the provided image name. + */ + public K6Container(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + /** + * Creates a new container instance based upon the provided image. + */ + public K6Container(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(K6_IMAGE); + } + + /** + * Specifies the test script to be executed within the container. + * @param testScript file to be copied into the container + * @return the builder + */ + public K6Container withTestScript(MountableFile testScript) { + this.testScript = "/home/k6/" + FilenameUtils.getName(testScript.getResolvedPath()); + withCopyFileToContainer(testScript, this.testScript); + return self(); + } + + /** + * Specifies additional command line options to be provided to the k6 command. + * @param options command line options + * @return the builder + */ + public K6Container withCmdOptions(String... options) { + cmdOptions.addAll(Arrays.asList(options)); + return self(); + } + + /** + * Adds a key-value pair for access within test scripts as an environment variable. + * @param key unique identifier for the variable + * @param value value of the variable + * @return the builder + */ + public K6Container withScriptVar(String key, String value) { + scriptVars.put(key, value); + return self(); + } + + /** + * {@inheritDoc} + */ + @Override + protected void configure() { + List commandParts = new ArrayList<>(); + commandParts.add("run"); + commandParts.addAll(cmdOptions); + for (Map.Entry entry : scriptVars.entrySet()) { + commandParts.add("--env"); + commandParts.add(String.format("%s=%s", entry.getKey(), entry.getValue())); + } + commandParts.add(testScript); + + setCommand(commandParts.toArray(new String[] {})); + } +} diff --git a/modules/k6/src/test/java/org/testcontainers/k6/K6ContainerTests.java b/modules/k6/src/test/java/org/testcontainers/k6/K6ContainerTests.java new file mode 100644 index 00000000000..43b390c4db4 --- /dev/null +++ b/modules/k6/src/test/java/org/testcontainers/k6/K6ContainerTests.java @@ -0,0 +1,43 @@ +package org.testcontainers.k6; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.output.WaitingConsumer; +import org.testcontainers.utility.MountableFile; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class K6ContainerTests { + + @Test + void k6StandardTest() throws Exception { + try ( + // standard_k6 { + K6Container container = new K6Container("grafana/k6:0.49.0") + .withTestScript(MountableFile.forClasspathResource("scripts/test.js")) + .withScriptVar("MY_SCRIPT_VAR", "are cool!") + .withScriptVar("AN_UNUSED_VAR", "unused") + .withCmdOptions("--quiet", "--no-usage-report") + // } + ) { + container.start(); + + // wait { + WaitingConsumer consumer = new WaitingConsumer(); + container.followOutput(consumer); + + // Wait for test script results to be collected + consumer.waitUntil( + frame -> { + return frame.getUtf8String().contains("iteration_duration"); + }, + 3, + TimeUnit.SECONDS + ); + // } + + assertThat(container.getLogs()).contains("k6 tests are cool!"); + } + } +} diff --git a/modules/k6/src/test/resources/logback-test.xml b/modules/k6/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/k6/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/k6/src/test/resources/scripts/test.js b/modules/k6/src/test/resources/scripts/test.js new file mode 100644 index 00000000000..fad0c4b48df --- /dev/null +++ b/modules/k6/src/test/resources/scripts/test.js @@ -0,0 +1,6 @@ +// access_script_vars { +// The most basic of k6 scripts. +export default function(){ + console.log(`k6 tests ${__ENV.MY_SCRIPT_VAR}`) +} +// } diff --git a/modules/kafka/build.gradle b/modules/kafka/build.gradle new file mode 100644 index 00000000000..aabad9553b0 --- /dev/null +++ b/modules/kafka/build.gradle @@ -0,0 +1,9 @@ +description = "Testcontainers :: Kafka" + +dependencies { + api project(':testcontainers') + + testImplementation 'org.apache.kafka:kafka-clients:4.3.1' + testImplementation 'com.google.guava:guava:23.0' + testImplementation 'org.awaitility:awaitility:4.3.0' +} diff --git a/modules/kafka/src/main/java/org/testcontainers/containers/KafkaContainer.java b/modules/kafka/src/main/java/org/testcontainers/containers/KafkaContainer.java new file mode 100644 index 00000000000..7eb836ade18 --- /dev/null +++ b/modules/kafka/src/main/java/org/testcontainers/containers/KafkaContainer.java @@ -0,0 +1,360 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * Testcontainers implementation for Apache Kafka. + * Zookeeper can be optionally configured. + *

+ * Supported image: {@code confluentinc/cp-kafka} + *

+ * Exposed ports: + *

    + *
  • Kafka: 9093
  • + *
  • Zookeeper: 2181
  • + *
+ * + * @deprecated use {@link org.testcontainers.kafka.ConfluentKafkaContainer} or + * {@link org.testcontainers.kafka.KafkaContainer} instead + */ +@Deprecated +public class KafkaContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("confluentinc/cp-kafka"); + + private static final String DEFAULT_TAG = "5.4.3"; + + public static final int KAFKA_PORT = 9093; + + public static final int ZOOKEEPER_PORT = 2181; + + private static final String DEFAULT_INTERNAL_TOPIC_RF = "1"; + + private static final String STARTER_SCRIPT = "/tmp/testcontainers_start.sh"; + + // https://docs.confluent.io/platform/7.0.0/release-notes/index.html#ak-raft-kraft + private static final String MIN_KRAFT_TAG = "7.0.0"; + + public static final String DEFAULT_CLUSTER_ID = "4L6g3nShT-eMCtK--X86sw"; + + protected String externalZookeeperConnect = null; + + private boolean kraftEnabled = false; + + private static final String PROTOCOL_PREFIX = "TC"; + + /** + * @deprecated use {@link #KafkaContainer(DockerImageName)} instead + */ + @Deprecated + public KafkaContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + /** + * @deprecated use {@link #KafkaContainer(DockerImageName)} instead + */ + @Deprecated + public KafkaContainer(String confluentPlatformVersion) { + this(DEFAULT_IMAGE_NAME.withTag(confluentPlatformVersion)); + } + + public KafkaContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + } + + @Override + KafkaContainerDef createContainerDef() { + return new KafkaContainerDef(); + } + + @Override + KafkaContainerDef getContainerDef() { + return (KafkaContainerDef) super.getContainerDef(); + } + + public KafkaContainer withEmbeddedZookeeper() { + if (this.kraftEnabled) { + throw new IllegalStateException("Cannot configure Zookeeper when using Kraft mode"); + } + this.externalZookeeperConnect = null; + return self(); + } + + public KafkaContainer withExternalZookeeper(String connectString) { + if (this.kraftEnabled) { + throw new IllegalStateException("Cannot configure Zookeeper when using Kraft mode"); + } + this.externalZookeeperConnect = connectString; + return self(); + } + + public KafkaContainer withKraft() { + if (this.externalZookeeperConnect != null) { + throw new IllegalStateException("Cannot configure Kraft mode when Zookeeper configured"); + } + verifyMinKraftVersion(); + this.kraftEnabled = true; + return self(); + } + + private void verifyMinKraftVersion() { + String actualVersion = DockerImageName.parse(getDockerImageName()).getVersionPart(); + if (new ComparableVersion(actualVersion).isLessThan(MIN_KRAFT_TAG)) { + throw new IllegalArgumentException( + String.format( + "Provided Confluent Platform's version %s is not supported in Kraft mode (must be %s or above)", + actualVersion, + MIN_KRAFT_TAG + ) + ); + } + } + + private boolean isLessThanCP740() { + String actualVersion = DockerImageName.parse(getDockerImageName()).getVersionPart(); + return new ComparableVersion(actualVersion).isLessThan("7.4.0"); + } + + public KafkaContainer withClusterId(String clusterId) { + Objects.requireNonNull(clusterId, "clusterId cannot be null"); + getContainerDef().withClusterId(clusterId); + return self(); + } + + public String getBootstrapServers() { + return String.format("PLAINTEXT://%s:%s", getHost(), getMappedPort(KAFKA_PORT)); + } + + @Override + protected void configure() { + getContainerDef().resolveListeners(); + + if (this.kraftEnabled) { + configureKraft(); + } else { + configureZookeeper(); + } + } + + protected void configureKraft() { + getContainerDef().withRaft(); + } + + protected void configureZookeeper() { + if (this.externalZookeeperConnect == null) { + getContainerDef().withEmbeddedZookeeper(); + } else { + getContainerDef().withZookeeper(this.externalZookeeperConnect); + } + } + + @Override + protected void containerIsStarting(InspectContainerResponse containerInfo) { + super.containerIsStarting(containerInfo); + + List advertisedListeners = new ArrayList<>(); + advertisedListeners.add(getBootstrapServers()); + advertisedListeners.add(brokerAdvertisedListener(containerInfo)); + + List> listenersToTransform = new ArrayList<>(getContainerDef().listeners); + for (int i = 0; i < listenersToTransform.size(); i++) { + Supplier listenerSupplier = listenersToTransform.get(i); + String protocol = String.format("%s-%d", PROTOCOL_PREFIX, i); + String listener = listenerSupplier.get(); + String listenerProtocol = String.format("%s://%s", protocol, listener); + advertisedListeners.add(listenerProtocol); + } + + String kafkaAdvertisedListeners = String.join(",", advertisedListeners); + + String command = "#!/bin/bash\n"; + // exporting KAFKA_ADVERTISED_LISTENERS with the container hostname + command += String.format("export KAFKA_ADVERTISED_LISTENERS=%s\n", kafkaAdvertisedListeners); + + if (!this.kraftEnabled || isLessThanCP740()) { + // Optimization: skip the checks + command += "echo '' > /etc/confluent/docker/ensure \n"; + } + + if (this.kraftEnabled) { + command += commandKraft(); + } else if (this.externalZookeeperConnect == null) { + command += commandZookeeper(); + } + + // Run the original command + command += "/etc/confluent/docker/run \n"; + copyFileToContainer(Transferable.of(command, 0777), STARTER_SCRIPT); + } + + protected String commandKraft() { + String command = "sed -i '/KAFKA_ZOOKEEPER_CONNECT/d' /etc/confluent/docker/configure\n"; + command += + "echo 'kafka-storage format --ignore-formatted -t \"" + + getContainerDef().getEnvVars().get("CLUSTER_ID") + + "\" -c /etc/kafka/kafka.properties' >> /etc/confluent/docker/configure\n"; + return command; + } + + protected String commandZookeeper() { + String command = "echo 'clientPort=" + ZOOKEEPER_PORT + "' > /tmp/zookeeper.properties\n"; + command += "echo 'dataDir=/var/lib/zookeeper/data' >> /tmp/zookeeper.properties\n"; + command += "echo 'dataLogDir=/var/lib/zookeeper/log' >> /tmp/zookeeper.properties\n"; + command += "zookeeper-server-start /tmp/zookeeper.properties &\n"; + return command; + } + + /** + * Add a {@link Supplier} that will provide a listener with format {@code host:port}. + * Host will be added as a network alias. + *

+ * The listener will be added to the list of default listeners. + *

+ * Default listeners: + *

    + *
  • 0.0.0.0:9092
  • + *
  • 0.0.0.0:9093
  • + *
+ *

+ * Default advertised listeners: + *

    + *
  • {@code container.getHost():container.getMappedPort(9093)}
  • + *
  • {@code container.getConfig().getHostName():9092}
  • + *
+ * @param listenerSupplier a supplier that will provide a listener + * @return this {@link KafkaContainer} instance + */ + public KafkaContainer withListener(Supplier listenerSupplier) { + getContainerDef().withListener(listenerSupplier); + return this; + } + + protected String brokerAdvertisedListener(InspectContainerResponse containerInfo) { + return String.format("BROKER://%s:%s", containerInfo.getConfig().getHostName(), "9092"); + } + + private static class KafkaContainerDef extends ContainerDef { + + private final Set> listeners = new HashSet<>(); + + private String clusterId = DEFAULT_CLUSTER_ID; + + KafkaContainerDef() { + // Use two listeners with different names, it will force Kafka to communicate with itself via internal + // listener when KAFKA_INTER_BROKER_LISTENER_NAME is set, otherwise Kafka will try to use the advertised listener + addEnvVar("KAFKA_LISTENERS", "PLAINTEXT://0.0.0.0:" + KAFKA_PORT + ",BROKER://0.0.0.0:9092"); + addEnvVar("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", "BROKER:PLAINTEXT,PLAINTEXT:PLAINTEXT"); + addEnvVar("KAFKA_INTER_BROKER_LISTENER_NAME", "BROKER"); + + addEnvVar("KAFKA_BROKER_ID", "1"); + addEnvVar("KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR", DEFAULT_INTERNAL_TOPIC_RF); + addEnvVar("KAFKA_OFFSETS_TOPIC_NUM_PARTITIONS", DEFAULT_INTERNAL_TOPIC_RF); + addEnvVar("KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR", DEFAULT_INTERNAL_TOPIC_RF); + addEnvVar("KAFKA_TRANSACTION_STATE_LOG_MIN_ISR", DEFAULT_INTERNAL_TOPIC_RF); + addEnvVar("KAFKA_LOG_FLUSH_INTERVAL_MESSAGES", Long.MAX_VALUE + ""); + addEnvVar("KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS", "0"); + + addExposedTcpPort(KAFKA_PORT); + + setEntrypoint("sh"); + setCommand("-c", "while [ ! -f " + STARTER_SCRIPT + " ]; do sleep 0.1; done; " + STARTER_SCRIPT); + + setWaitStrategy(Wait.forLogMessage(".*\\[KafkaServer id=\\d+\\] started.*", 1)); + } + + private void resolveListeners() { + Set listeners = Arrays + .stream(this.envVars.get("KAFKA_LISTENERS").split(",")) + .collect(Collectors.toSet()); + Set listenerSecurityProtocolMap = Arrays + .stream(this.envVars.get("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP").split(",")) + .collect(Collectors.toSet()); + + List> listenersToTransform = new ArrayList<>(this.listeners); + for (int i = 0; i < listenersToTransform.size(); i++) { + Supplier listenerSupplier = listenersToTransform.get(i); + String protocol = String.format("%s-%d", PROTOCOL_PREFIX, i); + String listener = listenerSupplier.get(); + String listenerPort = listener.split(":")[1]; + String listenerProtocol = String.format("%s://0.0.0.0:%s", protocol, listenerPort); + String protocolMap = String.format("%s:PLAINTEXT", protocol); + listeners.add(listenerProtocol); + listenerSecurityProtocolMap.add(protocolMap); + + String host = listener.split(":")[0]; + addNetworkAlias(host); + } + + String kafkaListeners = String.join(",", listeners); + String kafkaListenerSecurityProtocolMap = String.join(",", listenerSecurityProtocolMap); + + this.envVars.put("KAFKA_LISTENERS", kafkaListeners); + this.envVars.put("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", kafkaListenerSecurityProtocolMap); + } + + void withListener(Supplier listenerSupplier) { + this.listeners.add(listenerSupplier); + } + + void withEmbeddedZookeeper() { + addExposedTcpPort(ZOOKEEPER_PORT); + addEnvVar("KAFKA_ZOOKEEPER_CONNECT", "localhost:" + ZOOKEEPER_PORT); + } + + void withZookeeper(String connectionString) { + addEnvVar("KAFKA_ZOOKEEPER_CONNECT", connectionString); + } + + void withClusterId(String clusterId) { + this.clusterId = clusterId; + } + + void withRaft() { + this.envVars.computeIfAbsent("CLUSTER_ID", key -> clusterId); + this.envVars.computeIfAbsent("KAFKA_NODE_ID", key -> getEnvVars().get("KAFKA_BROKER_ID")); + addEnvVar("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", kafkaListenerSecurityProtocolMap()); + addEnvVar("KAFKA_LISTENERS", kafkaListeners()); + addEnvVar("KAFKA_PROCESS_ROLES", "broker,controller"); + + String controllerQuorumVoters = String.format("%s@localhost:9094", getEnvVars().get("KAFKA_NODE_ID")); + this.envVars.computeIfAbsent("KAFKA_CONTROLLER_QUORUM_VOTERS", key -> controllerQuorumVoters); + addEnvVar("KAFKA_CONTROLLER_LISTENER_NAMES", "CONTROLLER"); + + setWaitStrategy(Wait.forLogMessage(".*Transitioning from RECOVERY to RUNNING.*", 1)); + } + + private String kafkaListenerSecurityProtocolMap() { + String kafkaListenerSecurityProtocolMapEnvVar = getEnvVars().get("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP"); + String kafkaListenerSecurityProtocolMap = String.format( + "%s,CONTROLLER:PLAINTEXT", + kafkaListenerSecurityProtocolMapEnvVar + ); + Set listenerSecurityProtocolMap = new HashSet<>( + Arrays.asList(kafkaListenerSecurityProtocolMap.split(",")) + ); + return String.join(",", listenerSecurityProtocolMap); + } + + private String kafkaListeners() { + String kafkaListenersEnvVar = getEnvVars().get("KAFKA_LISTENERS"); + String kafkaListeners = String.format("%s,CONTROLLER://0.0.0.0:9094", kafkaListenersEnvVar); + Set listeners = new HashSet<>(Arrays.asList(kafkaListeners.split(","))); + return String.join(",", listeners); + } + } +} diff --git a/modules/kafka/src/main/java/org/testcontainers/kafka/ConfluentKafkaContainer.java b/modules/kafka/src/main/java/org/testcontainers/kafka/ConfluentKafkaContainer.java new file mode 100644 index 00000000000..381ba836715 --- /dev/null +++ b/modules/kafka/src/main/java/org/testcontainers/kafka/ConfluentKafkaContainer.java @@ -0,0 +1,137 @@ +package org.testcontainers.kafka; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; + +/** + * Testcontainers implementation for Confluent Kafka. + *

+ * Supported image: {@code confluentinc/cp-kafka} + *

+ * Exposed ports: 9092 + */ +public class ConfluentKafkaContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("confluentinc/cp-kafka"); + + private final Set listeners = new LinkedHashSet<>(); + + private final Set> advertisedListeners = new LinkedHashSet<>(); + + public ConfluentKafkaContainer(String imageName) { + this(DockerImageName.parse(imageName)); + } + + public ConfluentKafkaContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + withExposedPorts(KafkaHelper.KAFKA_PORT); + withEnv(KafkaHelper.envVars()); + + withCommand(KafkaHelper.COMMAND); + waitingFor(KafkaHelper.WAIT_STRATEGY); + } + + @Override + protected void configure() { + KafkaHelper.resolveListeners(this, this.listeners); + } + + @Override + protected void containerIsStarting(InspectContainerResponse containerInfo) { + String brokerAdvertisedListener = String.format( + "BROKER://%s:%s", + containerInfo.getConfig().getHostName(), + "9093" + ); + List advertisedListeners = new ArrayList<>(); + advertisedListeners.add("PLAINTEXT://" + getBootstrapServers()); + advertisedListeners.add(brokerAdvertisedListener); + + advertisedListeners.addAll(KafkaHelper.resolveAdvertisedListeners(this.advertisedListeners)); + String kafkaAdvertisedListeners = String.join(",", advertisedListeners); + + String command = "#!/bin/bash\n"; + // exporting KAFKA_ADVERTISED_LISTENERS with the container hostname + command += String.format("export KAFKA_ADVERTISED_LISTENERS=%s\n", kafkaAdvertisedListeners); + + command += "/etc/confluent/docker/run \n"; + copyFileToContainer(Transferable.of(command, 0777), KafkaHelper.STARTER_SCRIPT); + } + + /** + * Add a listener in the format {@code host:port}. + * Host will be included as a network alias. + *

+ * Use it to register additional connections to the Kafka broker within the same container network. + *

+ * The listener will be added to the list of default listeners. + *

+ * Default listeners: + *

    + *
  • 0.0.0.0:9092
  • + *
  • 0.0.0.0:9093
  • + *
  • 0.0.0.0:9094
  • + *
+ *

+ * The listener will be added to the list of default advertised listeners. + *

+ * Default advertised listeners: + *

    + *
  • {@code container.getHost():container.getMappedPort(9092)}
  • + *
  • {@code containerInfo.getConfig().getHostName():9093}
  • + *
+ * @param listener a listener with format {@code host:port} + * @return this {@link ConfluentKafkaContainer} instance + */ + public ConfluentKafkaContainer withListener(String listener) { + this.listeners.add(listener); + this.advertisedListeners.add(() -> listener); + return this; + } + + /** + * Add a listener in the format {@code host:port} and a {@link Supplier} for the advertised listener. + * Host from listener will be included as a network alias. + *

+ * Use it to register additional connections to the Kafka broker from outside the container network + *

+ * The listener will be added to the list of default listeners. + *

+ * Default listeners: + *

    + *
  • 0.0.0.0:9092
  • + *
  • 0.0.0.0:9093
  • + *
  • 0.0.0.0:9094
  • + *
+ *

+ * The {@link Supplier} will be added to the list of default advertised listeners. + *

+ * Default advertised listeners: + *

    + *
  • {@code container.getHost():container.getMappedPort(9092)}
  • + *
  • {@code containerInfo.getConfig().getHostName():9093}
  • + *
+ * @param listener a supplier that will provide a listener + * @param advertisedListener a supplier that will provide a listener + * @return this {@link ConfluentKafkaContainer} instance + */ + public ConfluentKafkaContainer withListener(String listener, Supplier advertisedListener) { + this.listeners.add(listener); + this.advertisedListeners.add(advertisedListener); + return this; + } + + public String getBootstrapServers() { + return String.format("%s:%s", getHost(), getMappedPort(KafkaHelper.KAFKA_PORT)); + } +} diff --git a/modules/kafka/src/main/java/org/testcontainers/kafka/KafkaContainer.java b/modules/kafka/src/main/java/org/testcontainers/kafka/KafkaContainer.java new file mode 100644 index 00000000000..375fd132f6c --- /dev/null +++ b/modules/kafka/src/main/java/org/testcontainers/kafka/KafkaContainer.java @@ -0,0 +1,143 @@ +package org.testcontainers.kafka; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; + +/** + * Testcontainers implementation for Apache Kafka. + *

+ * Supported image: {@code apache/kafka}, {@code apache/kafka-native} + *

+ * Exposed ports: 9092 + */ +public class KafkaContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("apache/kafka"); + + private static final DockerImageName APACHE_KAFKA_NATIVE_IMAGE_NAME = DockerImageName.parse("apache/kafka-native"); + + private static final int KAFKA_PORT = 9092; + + private static final String STARTER_SCRIPT = "/tmp/testcontainers_start.sh"; + + private final Set listeners = new LinkedHashSet<>(); + + private final Set> advertisedListeners = new LinkedHashSet<>(); + + public KafkaContainer(String imageName) { + this(DockerImageName.parse(imageName)); + } + + public KafkaContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, APACHE_KAFKA_NATIVE_IMAGE_NAME); + + withExposedPorts(KAFKA_PORT); + withEnv(KafkaHelper.envVars()); + + withCommand(KafkaHelper.COMMAND); + waitingFor(KafkaHelper.WAIT_STRATEGY); + } + + @Override + protected void configure() { + KafkaHelper.resolveListeners(this, this.listeners); + } + + @Override + protected void containerIsStarting(InspectContainerResponse containerInfo) { + String brokerAdvertisedListener = String.format( + "BROKER://%s:%s", + containerInfo.getConfig().getHostName(), + "9093" + ); + List advertisedListeners = new ArrayList<>(); + advertisedListeners.add("PLAINTEXT://" + getBootstrapServers()); + advertisedListeners.add(brokerAdvertisedListener); + + advertisedListeners.addAll(KafkaHelper.resolveAdvertisedListeners(this.advertisedListeners)); + String kafkaAdvertisedListeners = String.join(",", advertisedListeners); + + String command = "#!/bin/bash\n"; + // exporting KAFKA_ADVERTISED_LISTENERS with the container hostname + command += String.format("export KAFKA_ADVERTISED_LISTENERS=%s\n", kafkaAdvertisedListeners); + + command += "/etc/kafka/docker/run \n"; + copyFileToContainer(Transferable.of(command, 0777), STARTER_SCRIPT); + } + + /** + * Add a listener in the format {@code host:port}. + * Host will be included as a network alias. + *

+ * Use it to register additional connections to the Kafka broker within the same container network. + *

+ * The listener will be added to the list of default listeners. + *

+ * Default listeners: + *

    + *
  • 0.0.0.0:9092
  • + *
  • 0.0.0.0:9093
  • + *
  • 0.0.0.0:9094
  • + *
+ *

+ * The listener will be added to the list of default advertised listeners. + *

+ * Default advertised listeners: + *

    + *
  • {@code container.getConfig().getHostName():9092}
  • + *
  • {@code container.getHost():container.getMappedPort(9093)}
  • + *
+ * @param listener a listener with format {@code host:port} + * @return this {@link KafkaContainer} instance + */ + public KafkaContainer withListener(String listener) { + this.listeners.add(listener); + this.advertisedListeners.add(() -> listener); + return this; + } + + /** + * Add a listener in the format {@code host:port} and a {@link Supplier} for the advertised listener. + * Host from listener will be included as a network alias. + *

+ * Use it to register additional connections to the Kafka broker from outside the container network + *

+ * The listener will be added to the list of default listeners. + *

+ * Default listeners: + *

    + *
  • 0.0.0.0:9092
  • + *
  • 0.0.0.0:9093
  • + *
  • 0.0.0.0:9094
  • + *
+ *

+ * The {@link Supplier} will be added to the list of default advertised listeners. + *

+ * Default advertised listeners: + *

    + *
  • {@code container.getConfig().getHostName():9092}
  • + *
  • {@code container.getHost():container.getMappedPort(9093)}
  • + *
+ * @param listener a supplier that will provide a listener + * @param advertisedListener a supplier that will provide a listener + * @return this {@link KafkaContainer} instance + */ + public KafkaContainer withListener(String listener, Supplier advertisedListener) { + this.listeners.add(listener); + this.advertisedListeners.add(advertisedListener); + return this; + } + + public String getBootstrapServers() { + return String.format("%s:%s", getHost(), getMappedPort(KAFKA_PORT)); + } +} diff --git a/modules/kafka/src/main/java/org/testcontainers/kafka/KafkaHelper.java b/modules/kafka/src/main/java/org/testcontainers/kafka/KafkaHelper.java new file mode 100644 index 00000000000..61e790d474f --- /dev/null +++ b/modules/kafka/src/main/java/org/testcontainers/kafka/KafkaHelper.java @@ -0,0 +1,108 @@ +package org.testcontainers.kafka; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitStrategy; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +class KafkaHelper { + + private static final String DEFAULT_INTERNAL_TOPIC_RF = "1"; + + private static final String DEFAULT_CLUSTER_ID = "4L6g3nShT-eMCtK--X86sw"; + + private static final String PROTOCOL_PREFIX = "TC"; + + static final int KAFKA_PORT = 9092; + + static final String STARTER_SCRIPT = "/tmp/testcontainers_start.sh"; + + static final String[] COMMAND = { + "sh", + "-c", + "while [ ! -f " + STARTER_SCRIPT + " ]; do sleep 0.1; done; " + STARTER_SCRIPT, + }; + + static final WaitStrategy WAIT_STRATEGY = Wait.forLogMessage(".*Transitioning from RECOVERY to RUNNING.*", 1); + + static Map envVars() { + Map envVars = new HashMap<>(); + envVars.put("CLUSTER_ID", DEFAULT_CLUSTER_ID); + + envVars.put( + "KAFKA_LISTENERS", + "PLAINTEXT://0.0.0.0:" + KAFKA_PORT + ",BROKER://0.0.0.0:9093,CONTROLLER://0.0.0.0:9094" + ); + envVars.put( + "KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", + "BROKER:PLAINTEXT,PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT" + ); + envVars.put("KAFKA_INTER_BROKER_LISTENER_NAME", "BROKER"); + envVars.put("KAFKA_PROCESS_ROLES", "broker,controller"); + envVars.put("KAFKA_CONTROLLER_LISTENER_NAMES", "CONTROLLER"); + + envVars.put("KAFKA_NODE_ID", "1"); + + String controllerQuorumVoters = String.format("%s@localhost:9094", envVars.get("KAFKA_NODE_ID")); + envVars.put("KAFKA_CONTROLLER_QUORUM_VOTERS", controllerQuorumVoters); + + envVars.put("KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR", DEFAULT_INTERNAL_TOPIC_RF); + envVars.put("KAFKA_OFFSETS_TOPIC_NUM_PARTITIONS", DEFAULT_INTERNAL_TOPIC_RF); + envVars.put("KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR", DEFAULT_INTERNAL_TOPIC_RF); + envVars.put("KAFKA_TRANSACTION_STATE_LOG_MIN_ISR", DEFAULT_INTERNAL_TOPIC_RF); + envVars.put("KAFKA_LOG_FLUSH_INTERVAL_MESSAGES", Long.MAX_VALUE + ""); + envVars.put("KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS", "0"); + return envVars; + } + + static void resolveListeners(GenericContainer kafkaContainer, Set listenersSuppliers) { + Set listeners = Arrays + .stream(kafkaContainer.getEnvMap().get("KAFKA_LISTENERS").split(",")) + .collect(Collectors.toSet()); + Set listenerSecurityProtocolMap = Arrays + .stream(kafkaContainer.getEnvMap().get("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP").split(",")) + .collect(Collectors.toSet()); + + List listenersToTransform = new ArrayList<>(listenersSuppliers); + for (int i = 0; i < listenersToTransform.size(); i++) { + String protocol = String.format("%s-%d", PROTOCOL_PREFIX, i); + String listener = listenersToTransform.get(i); + String listenerHost = listener.split(":")[0]; + String listenerPort = listener.split(":")[1]; + String listenerProtocol = String.format("%s://%s:%s", protocol, listenerHost, listenerPort); + String protocolMap = String.format("%s:PLAINTEXT", protocol); + listeners.add(listenerProtocol); + listenerSecurityProtocolMap.add(protocolMap); + + String host = listener.split(":")[0]; + kafkaContainer.withNetworkAliases(host); + } + + String kafkaListeners = String.join(",", listeners); + String kafkaListenerSecurityProtocolMap = String.join(",", listenerSecurityProtocolMap); + + kafkaContainer.getEnvMap().put("KAFKA_LISTENERS", kafkaListeners); + kafkaContainer.getEnvMap().put("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", kafkaListenerSecurityProtocolMap); + } + + static List resolveAdvertisedListeners(Set> listenerSuppliers) { + List advertisedListeners = new ArrayList<>(); + List> listenersToTransform = new ArrayList<>(listenerSuppliers); + for (int i = 0; i < listenersToTransform.size(); i++) { + Supplier listenerSupplier = listenersToTransform.get(i); + String protocol = String.format("%s-%d", PROTOCOL_PREFIX, i); + String listener = listenerSupplier.get(); + String listenerProtocol = String.format("%s://%s", protocol, listener); + advertisedListeners.add(listenerProtocol); + } + return advertisedListeners; + } +} diff --git a/modules/kafka/src/test/java/org/testcontainers/AbstractKafka.java b/modules/kafka/src/test/java/org/testcontainers/AbstractKafka.java new file mode 100644 index 00000000000..90977e61c18 --- /dev/null +++ b/modules/kafka/src/test/java/org/testcontainers/AbstractKafka.java @@ -0,0 +1,153 @@ +package org.testcontainers; + +import com.google.common.collect.ImmutableMap; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.config.SaslConfigs; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.awaitility.Awaitility; + +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +public class AbstractKafka { + + private static final ImmutableMap PLAIN_PROPERTIES = ImmutableMap.of( + AdminClientConfig.SECURITY_PROTOCOL_CONFIG, + "SASL_PLAINTEXT", + SaslConfigs.SASL_MECHANISM, + "PLAIN", + SaslConfigs.SASL_JAAS_CONFIG, + "org.apache.kafka.common.security.plain.PlainLoginModule required username=\"admin\" password=\"admin\";" + ); + + private static final ImmutableMap SCRAM_PROPERTIES = ImmutableMap.of( + AdminClientConfig.SECURITY_PROTOCOL_CONFIG, + "SASL_PLAINTEXT", + SaslConfigs.SASL_MECHANISM, + "SCRAM-SHA-256", + SaslConfigs.SASL_JAAS_CONFIG, + "org.apache.kafka.common.security.scram.ScramLoginModule required username=\"admin\" password=\"admin\";" + ); + + protected void testKafkaFunctionality(String bootstrapServers) throws Exception { + testKafkaFunctionality(bootstrapServers, false, 1, 1); + } + + protected void testSecurePlainKafkaFunctionality(String bootstrapServers) throws Exception { + testKafkaFunctionality(bootstrapServers, true, PLAIN_PROPERTIES, 1, 1); + } + + protected void testSecureScramKafkaFunctionality(String bootstrapServers) throws Exception { + testKafkaFunctionality(bootstrapServers, true, SCRAM_PROPERTIES, 1, 1); + } + + protected void testKafkaFunctionality(String bootstrapServers, boolean authenticated, int partitions, int rf) + throws Exception { + testKafkaFunctionality(bootstrapServers, authenticated, Collections.emptyMap(), partitions, rf); + } + + protected void testKafkaFunctionality( + String bootstrapServers, + boolean authenticated, + Map authProperties, + int partitions, + int rf + ) throws Exception { + ImmutableMap adminClientDefaultProperties = ImmutableMap.of( + AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, + bootstrapServers + ); + Properties adminClientProperties = new Properties(); + adminClientProperties.putAll(adminClientDefaultProperties); + + ImmutableMap consumerDefaultProperties = ImmutableMap.of( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, + bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG, + "tc-" + UUID.randomUUID(), + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, + "earliest" + ); + Properties consumerProperties = new Properties(); + consumerProperties.putAll(consumerDefaultProperties); + + ImmutableMap producerDefaultProperties = ImmutableMap.of( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, + bootstrapServers, + ProducerConfig.CLIENT_ID_CONFIG, + UUID.randomUUID().toString() + ); + Properties producerProperties = new Properties(); + producerProperties.putAll(producerDefaultProperties); + + if (authenticated) { + adminClientProperties.putAll(authProperties); + consumerProperties.putAll(authProperties); + producerProperties.putAll(authProperties); + } + try ( + AdminClient adminClient = AdminClient.create(adminClientProperties); + KafkaProducer producer = new KafkaProducer<>( + producerProperties, + new StringSerializer(), + new StringSerializer() + ); + KafkaConsumer consumer = new KafkaConsumer<>( + consumerProperties, + new StringDeserializer(), + new StringDeserializer() + ); + ) { + String topicName = "messages-" + UUID.randomUUID(); + + Collection topics = Collections.singletonList(new NewTopic(topicName, partitions, (short) rf)); + adminClient.createTopics(topics).all().get(30, TimeUnit.SECONDS); + + consumer.subscribe(Collections.singletonList(topicName)); + + producer.send(new ProducerRecord<>(topicName, "testcontainers", "rulezzz")).get(); + + Awaitility + .await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + ConsumerRecords records = consumer.poll(Duration.ofMillis(100)); + + assertThat(records) + .hasSize(1) + .extracting(ConsumerRecord::topic, ConsumerRecord::key, ConsumerRecord::value) + .containsExactly(tuple(topicName, "testcontainers", "rulezzz")); + }); + + consumer.unsubscribe(); + } + } + + protected static String getJaasConfig() { + String jaasConfig = + "org.apache.kafka.common.security.plain.PlainLoginModule required " + + "username=\"admin\" " + + "password=\"admin\" " + + "user_admin=\"admin\" " + + "user_test=\"secret\";"; + return jaasConfig; + } +} diff --git a/modules/kafka/src/test/java/org/testcontainers/KCatContainer.java b/modules/kafka/src/test/java/org/testcontainers/KCatContainer.java new file mode 100644 index 00000000000..79532ae9f22 --- /dev/null +++ b/modules/kafka/src/test/java/org/testcontainers/KCatContainer.java @@ -0,0 +1,16 @@ +package org.testcontainers; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.images.builder.Transferable; + +public class KCatContainer extends GenericContainer { + + public KCatContainer() { + super("confluentinc/cp-kcat:7.9.0"); + withCreateContainerCmdModifier(cmd -> { + cmd.withEntrypoint("sh"); + }); + withCopyToContainer(Transferable.of("Message produced by kcat"), "/data/msgs.txt"); + withCommand("-c", "tail -f /dev/null"); + } +} diff --git a/modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java b/modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java new file mode 100644 index 00000000000..e244d7a2e26 --- /dev/null +++ b/modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java @@ -0,0 +1,346 @@ +package org.testcontainers.containers; + +import com.google.common.collect.ImmutableMap; +import lombok.SneakyThrows; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.common.config.SaslConfigs; +import org.apache.kafka.common.errors.SaslAuthenticationException; +import org.apache.kafka.common.errors.TopicAuthorizationException; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.testcontainers.AbstractKafka; +import org.testcontainers.Testcontainers; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class KafkaContainerTest extends AbstractKafka { + + private static final DockerImageName KAFKA_TEST_IMAGE = DockerImageName.parse("confluentinc/cp-kafka:6.2.1"); + + private static final DockerImageName KAFKA_KRAFT_TEST_IMAGE = DockerImageName.parse("confluentinc/cp-kafka:7.0.1"); + + private static final DockerImageName ZOOKEEPER_TEST_IMAGE = DockerImageName.parse( + "confluentinc/cp-zookeeper:4.0.0" + ); + + @Test + void testUsage() throws Exception { + try (KafkaContainer kafka = new KafkaContainer(KAFKA_TEST_IMAGE)) { + kafka.start(); + testKafkaFunctionality(kafka.getBootstrapServers()); + } + } + + @Test + void testUsageWithSpecificImage() throws Exception { + try ( + // constructorWithVersion { + KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1")) + // } + ) { + kafka.start(); + testKafkaFunctionality( + // getBootstrapServers { + kafka.getBootstrapServers() + // } + ); + } + } + + @Test + void testUsageWithVersion() throws Exception { + try (KafkaContainer kafka = new KafkaContainer("6.2.1")) { + kafka.start(); + testKafkaFunctionality(kafka.getBootstrapServers()); + } + } + + @Test + void testExternalZookeeperWithExternalNetwork() throws Exception { + try ( + Network network = Network.newNetwork(); + // withExternalZookeeper { + KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1")) + .withNetwork(network) + .withExternalZookeeper("zookeeper:2181"); + // } + + GenericContainer zookeeper = new GenericContainer<>(ZOOKEEPER_TEST_IMAGE) + .withNetwork(network) + .withNetworkAliases("zookeeper") + .withEnv("ZOOKEEPER_CLIENT_PORT", "2181"); + ) { + zookeeper.start(); + kafka.start(); + + testKafkaFunctionality(kafka.getBootstrapServers()); + } + } + + @Test + void testConfluentPlatformVersion7() throws Exception { + try (KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.2.2"))) { + kafka.start(); + testKafkaFunctionality(kafka.getBootstrapServers()); + } + } + + @Test + void testConfluentPlatformVersion5() throws Exception { + try (KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:5.4.3"))) { + kafka.start(); + testKafkaFunctionality(kafka.getBootstrapServers()); + } + } + + @Test + void testWithHostExposedPort() throws Exception { + Testcontainers.exposeHostPorts(12345); + try (KafkaContainer kafka = new KafkaContainer(KAFKA_TEST_IMAGE)) { + kafka.start(); + testKafkaFunctionality(kafka.getBootstrapServers()); + } + } + + @Test + void testWithHostExposedPortAndExternalNetwork() throws Exception { + Testcontainers.exposeHostPorts(12345); + try (KafkaContainer kafka = new KafkaContainer(KAFKA_TEST_IMAGE).withNetwork(Network.newNetwork())) { + kafka.start(); + testKafkaFunctionality(kafka.getBootstrapServers()); + } + } + + @Test + void testUsageKraftBeforeConfluentPlatformVersion74() throws Exception { + try ( + KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.0.1")).withKraft() + ) { + kafka.start(); + testKafkaFunctionality(kafka.getBootstrapServers()); + } + } + + @Test + void testUsageKraftAfterConfluentPlatformVersion74() throws Exception { + try ( + // withKraftMode { + KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0")).withKraft() + // } + ) { + kafka.start(); + testKafkaFunctionality(kafka.getBootstrapServers()); + } + } + + @Test + void testNotSupportedKraftVersion() { + try ( + KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1")).withKraft() + ) {} catch (IllegalArgumentException e) { + assertThat(e.getMessage()) + .isEqualTo( + "Provided Confluent Platform's version 6.2.1 is not supported in Kraft mode (must be 7.0.0 or above)" + ); + } + } + + @Test + void testKraftZookeeperMutualExclusion() { + try ( + KafkaContainer kafka = new KafkaContainer(KAFKA_KRAFT_TEST_IMAGE).withKraft().withExternalZookeeper("") + ) {} catch (IllegalStateException e) { + assertThat(e.getMessage()).isEqualTo("Cannot configure Zookeeper when using Kraft mode"); + } + + try ( + KafkaContainer kafka = new KafkaContainer(KAFKA_KRAFT_TEST_IMAGE).withExternalZookeeper("").withKraft() + ) {} catch (IllegalStateException e) { + assertThat(e.getMessage()).isEqualTo("Cannot configure Kraft mode when Zookeeper configured"); + } + + try ( + KafkaContainer kafka = new KafkaContainer(KAFKA_KRAFT_TEST_IMAGE).withKraft().withEmbeddedZookeeper() + ) {} catch (IllegalStateException e) { + assertThat(e.getMessage()).isEqualTo("Cannot configure Zookeeper when using Kraft mode"); + } + } + + @Test + void testKraftPrecedenceOverEmbeddedZookeeper() throws Exception { + try (KafkaContainer kafka = new KafkaContainer(KAFKA_KRAFT_TEST_IMAGE).withEmbeddedZookeeper().withKraft()) { + kafka.start(); + testKafkaFunctionality(kafka.getBootstrapServers()); + } + } + + @Test + void testUsageWithListener() throws Exception { + try ( + Network network = Network.newNetwork(); + KafkaContainer kafka = new KafkaContainer(KAFKA_KRAFT_TEST_IMAGE) + .withListener(() -> "kafka:19092") + .withNetwork(network); + // createKCatContainer { + GenericContainer kcat = new GenericContainer<>("confluentinc/cp-kcat:7.9.0") + .withCreateContainerCmdModifier(cmd -> { + cmd.withEntrypoint("sh"); + }) + .withCopyToContainer(Transferable.of("Message produced by kcat"), "/data/msgs.txt") + .withNetwork(network) + .withCommand("-c", "tail -f /dev/null") + // } + ) { + kafka.start(); + kcat.start(); + // produceConsumeMessage { + kcat.execInContainer("kcat", "-b", "kafka:19092", "-t", "msgs", "-P", "-l", "/data/msgs.txt"); + String stdout = kcat + .execInContainer("kcat", "-b", "kafka:19092", "-C", "-t", "msgs", "-c", "1") + .getStdout(); + // } + assertThat(stdout).contains("Message produced by kcat"); + } + } + + @SneakyThrows + @Test + void shouldConfigureAuthenticationWithSaslUsingJaas() { + try ( + KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1")) + .withEnv("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", "PLAINTEXT:SASL_PLAINTEXT,BROKER:SASL_PLAINTEXT") + .withEnv("KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL", "PLAIN") + .withEnv("KAFKA_LISTENER_NAME_PLAINTEXT_SASL_ENABLED_MECHANISMS", "PLAIN") + .withEnv("KAFKA_LISTENER_NAME_BROKER_SASL_ENABLED_MECHANISMS", "PLAIN") + .withEnv("KAFKA_LISTENER_NAME_BROKER_PLAIN_SASL_JAAS_CONFIG", getJaasConfig()) + .withEnv("KAFKA_LISTENER_NAME_PLAINTEXT_PLAIN_SASL_JAAS_CONFIG", getJaasConfig()) + ) { + kafka.start(); + + testSecurePlainKafkaFunctionality(kafka.getBootstrapServers()); + } + } + + @SneakyThrows + @Test + void shouldConfigureAuthenticationWithSaslScramUsingJaas() { + try ( + KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.7.0")) { + protected String commandKraft() { + String command = "sed -i '/KAFKA_ZOOKEEPER_CONNECT/d' /etc/confluent/docker/configure\n"; + command += + "echo 'kafka-storage format --ignore-formatted -t \"" + + "$CLUSTER_ID" + + "\" --add-scram SCRAM-SHA-256=[name=admin,password=admin] -c /etc/kafka/kafka.properties' >> /etc/confluent/docker/configure\n"; + return command; + } + } + .withKraft() + .withEnv("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", "PLAINTEXT:SASL_PLAINTEXT,BROKER:SASL_PLAINTEXT") + .withEnv("KAFKA_LISTENER_NAME_PLAINTEXT_SASL_ENABLED_MECHANISMS", "SCRAM-SHA-256") + .withEnv("KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL", "SCRAM-SHA-256") + .withEnv("KAFKA_SASL_ENABLED_MECHANISMS", "SCRAM-SHA-256") + .withEnv("KAFKA_OPTS", "-Djava.security.auth.login.config=/etc/kafka/secrets/kafka_server_jaas.conf") + .withCopyFileToContainer( + MountableFile.forClasspathResource("kafka_server_jaas.conf"), + "/etc/kafka/secrets/kafka_server_jaas.conf" + ) + ) { + kafka.start(); + + testSecureScramKafkaFunctionality(kafka.getBootstrapServers()); + } + } + + @SneakyThrows + @Test + void enableSaslWithUnsuccessfulTopicCreation() { + try ( + KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1")) + .withEnv("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", "PLAINTEXT:SASL_PLAINTEXT,BROKER:SASL_PLAINTEXT") + .withEnv("KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL", "PLAIN") + .withEnv("KAFKA_LISTENER_NAME_PLAINTEXT_SASL_ENABLED_MECHANISMS", "PLAIN") + .withEnv("KAFKA_LISTENER_NAME_BROKER_SASL_ENABLED_MECHANISMS", "PLAIN") + .withEnv("KAFKA_LISTENER_NAME_BROKER_PLAIN_SASL_JAAS_CONFIG", getJaasConfig()) + .withEnv("KAFKA_LISTENER_NAME_PLAINTEXT_PLAIN_SASL_JAAS_CONFIG", getJaasConfig()) + .withEnv("KAFKA_AUTHORIZER_CLASS_NAME", "kafka.security.authorizer.AclAuthorizer") + .withEnv("KAFKA_SUPER_USERS", "User:admin") + ) { + kafka.start(); + + AdminClient adminClient = AdminClient.create( + ImmutableMap.of( + AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, + kafka.getBootstrapServers(), + AdminClientConfig.SECURITY_PROTOCOL_CONFIG, + "SASL_PLAINTEXT", + SaslConfigs.SASL_MECHANISM, + "PLAIN", + SaslConfigs.SASL_JAAS_CONFIG, + "org.apache.kafka.common.security.plain.PlainLoginModule required username=\"test\" password=\"secret\";" + ) + ); + + String topicName = "messages-" + UUID.randomUUID(); + Collection topics = Collections.singletonList(new NewTopic(topicName, 1, (short) 1)); + + Awaitility + .await() + .untilAsserted(() -> { + assertThatThrownBy(() -> adminClient.createTopics(topics).all().get(30, TimeUnit.SECONDS)) + .hasCauseInstanceOf(TopicAuthorizationException.class); + }); + } + } + + @SneakyThrows + @Test + void enableSaslAndWithAuthenticationError() { + String jaasConfig = getJaasConfig(); + try ( + KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1")) + .withEnv("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", "PLAINTEXT:SASL_PLAINTEXT,BROKER:SASL_PLAINTEXT") + .withEnv("KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL", "PLAIN") + .withEnv("KAFKA_LISTENER_NAME_PLAINTEXT_SASL_ENABLED_MECHANISMS", "PLAIN") + .withEnv("KAFKA_LISTENER_NAME_BROKER_SASL_ENABLED_MECHANISMS", "PLAIN") + .withEnv("KAFKA_LISTENER_NAME_BROKER_PLAIN_SASL_JAAS_CONFIG", jaasConfig) + .withEnv("KAFKA_LISTENER_NAME_PLAINTEXT_PLAIN_SASL_JAAS_CONFIG", jaasConfig) + ) { + kafka.start(); + + AdminClient adminClient = AdminClient.create( + ImmutableMap.of( + AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, + kafka.getBootstrapServers(), + AdminClientConfig.SECURITY_PROTOCOL_CONFIG, + "SASL_PLAINTEXT", + SaslConfigs.SASL_MECHANISM, + "PLAIN", + SaslConfigs.SASL_JAAS_CONFIG, + "org.apache.kafka.common.security.plain.PlainLoginModule required username=\"test\" password=\"secretx\";" + ) + ); + + String topicName = "messages-" + UUID.randomUUID(); + Collection topics = Collections.singletonList(new NewTopic(topicName, 1, (short) 1)); + + Awaitility + .await() + .untilAsserted(() -> { + assertThatThrownBy(() -> adminClient.createTopics(topics).all().get(30, TimeUnit.SECONDS)) + .hasCauseInstanceOf(SaslAuthenticationException.class); + }); + } + } +} diff --git a/modules/kafka/src/test/java/org/testcontainers/kafka/CompatibleApacheKafkaImageTest.java b/modules/kafka/src/test/java/org/testcontainers/kafka/CompatibleApacheKafkaImageTest.java new file mode 100644 index 00000000000..33916978acf --- /dev/null +++ b/modules/kafka/src/test/java/org/testcontainers/kafka/CompatibleApacheKafkaImageTest.java @@ -0,0 +1,21 @@ +package org.testcontainers.kafka; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.AbstractKafka; + +public class CompatibleApacheKafkaImageTest extends AbstractKafka { + + public static String[] params() { + return new String[] { "apache/kafka:3.8.0", "apache/kafka-native:3.8.0" }; + } + + @ParameterizedTest + @MethodSource("params") + public void testUsage(String imageName) throws Exception { + try (KafkaContainer kafka = new KafkaContainer(imageName)) { + kafka.start(); + testKafkaFunctionality(kafka.getBootstrapServers()); + } + } +} diff --git a/modules/kafka/src/test/java/org/testcontainers/kafka/ConfluentKafkaContainerTest.java b/modules/kafka/src/test/java/org/testcontainers/kafka/ConfluentKafkaContainerTest.java new file mode 100644 index 00000000000..1c95bf95e02 --- /dev/null +++ b/modules/kafka/src/test/java/org/testcontainers/kafka/ConfluentKafkaContainerTest.java @@ -0,0 +1,124 @@ +package org.testcontainers.kafka; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; +import org.testcontainers.AbstractKafka; +import org.testcontainers.KCatContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.SocatContainer; +import org.testcontainers.utility.MountableFile; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConfluentKafkaContainerTest extends AbstractKafka { + + @Test + void testUsage() throws Exception { + try ( // constructorWithVersion { + ConfluentKafkaContainer kafka = new ConfluentKafkaContainer("confluentinc/cp-kafka:7.4.0") + // } + ) { + kafka.start(); + testKafkaFunctionality(kafka.getBootstrapServers()); + } + } + + @Test + void testUsageWithListener() throws Exception { + try ( + Network network = Network.newNetwork(); + // registerListener { + ConfluentKafkaContainer kafka = new ConfluentKafkaContainer("confluentinc/cp-kafka:7.4.0") + .withListener("kafka:19092") + .withNetwork(network); + // } + KCatContainer kcat = new KCatContainer().withNetwork(network) + ) { + kafka.start(); + kcat.start(); + + kcat.execInContainer("kcat", "-b", "kafka:19092", "-t", "msgs", "-P", "-l", "/data/msgs.txt"); + String stdout = kcat + .execInContainer("kcat", "-b", "kafka:19092", "-C", "-t", "msgs", "-c", "1") + .getStdout(); + + assertThat(stdout).contains("Message produced by kcat"); + } + } + + @Test + void testUsageWithListenerFromProxy() throws Exception { + try ( + Network network = Network.newNetwork(); + // registerListenerFromProxy { + SocatContainer socat = new SocatContainer().withNetwork(network).withTarget(2000, "kafka", 19092); + ConfluentKafkaContainer kafka = new ConfluentKafkaContainer("confluentinc/cp-kafka:7.4.0") + .withListener("kafka:19092", () -> socat.getHost() + ":" + socat.getMappedPort(2000)) + .withNetwork(network) + // } + ) { + socat.start(); + kafka.start(); + + String bootstrapServers = String.format("%s:%s", socat.getHost(), socat.getMappedPort(2000)); + testKafkaFunctionality(bootstrapServers); + } + } + + @SneakyThrows + @Test + void shouldConfigureAuthenticationWithSaslUsingJaas() { + try ( + ConfluentKafkaContainer kafka = new ConfluentKafkaContainer("confluentinc/cp-kafka:7.7.0") + .withEnv( + "KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", + "PLAINTEXT:SASL_PLAINTEXT,BROKER:SASL_PLAINTEXT,CONTROLLER:PLAINTEXT" + ) + .withEnv("KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL", "PLAIN") + .withEnv("KAFKA_LISTENER_NAME_PLAINTEXT_SASL_ENABLED_MECHANISMS", "PLAIN") + .withEnv("KAFKA_LISTENER_NAME_BROKER_SASL_ENABLED_MECHANISMS", "PLAIN") + .withEnv("KAFKA_LISTENER_NAME_BROKER_PLAIN_SASL_JAAS_CONFIG", getJaasConfig()) + .withEnv("KAFKA_LISTENER_NAME_PLAINTEXT_PLAIN_SASL_JAAS_CONFIG", getJaasConfig()) + ) { + kafka.start(); + + testSecurePlainKafkaFunctionality(kafka.getBootstrapServers()); + } + } + + @SneakyThrows + @Test + void shouldConfigureAuthenticationWithSaslScramUsingJaas() { + try ( + ConfluentKafkaContainer kafka = new ConfluentKafkaContainer("confluentinc/cp-kafka:7.7.0") { + @SneakyThrows + @Override + protected void containerIsStarting(InspectContainerResponse containerInfo) { + String command = + "echo 'kafka-storage format --ignore-formatted -t \"" + + "$CLUSTER_ID" + + "\" --add-scram SCRAM-SHA-256=[name=admin,password=admin] -c /etc/kafka/kafka.properties' >> /etc/confluent/docker/configure"; + execInContainer("bash", "-c", command); + super.containerIsStarting(containerInfo); + } + } + .withEnv( + "KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", + "PLAINTEXT:SASL_PLAINTEXT,BROKER:SASL_PLAINTEXT,CONTROLLER:PLAINTEXT" + ) + .withEnv("KAFKA_LISTENER_NAME_PLAINTEXT_SASL_ENABLED_MECHANISMS", "SCRAM-SHA-256") + .withEnv("KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL", "SCRAM-SHA-256") + .withEnv("KAFKA_SASL_ENABLED_MECHANISMS", "SCRAM-SHA-256") + .withEnv("KAFKA_OPTS", "-Djava.security.auth.login.config=/etc/kafka/secrets/kafka_server_jaas.conf") + .withCopyFileToContainer( + MountableFile.forClasspathResource("kafka_server_jaas.conf"), + "/etc/kafka/secrets/kafka_server_jaas.conf" + ) + ) { + kafka.start(); + + testSecureScramKafkaFunctionality(kafka.getBootstrapServers()); + } + } +} diff --git a/modules/kafka/src/test/java/org/testcontainers/kafka/KafkaContainerTest.java b/modules/kafka/src/test/java/org/testcontainers/kafka/KafkaContainerTest.java new file mode 100644 index 00000000000..34af2e1784d --- /dev/null +++ b/modules/kafka/src/test/java/org/testcontainers/kafka/KafkaContainerTest.java @@ -0,0 +1,63 @@ +package org.testcontainers.kafka; + +import org.junit.jupiter.api.Test; +import org.testcontainers.AbstractKafka; +import org.testcontainers.KCatContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.SocatContainer; + +import static org.assertj.core.api.Assertions.assertThat; + +class KafkaContainerTest extends AbstractKafka { + + @Test + void testUsage() throws Exception { + try ( // constructorWithVersion { + KafkaContainer kafka = new KafkaContainer("apache/kafka-native:3.8.0") + // } + ) { + kafka.start(); + testKafkaFunctionality(kafka.getBootstrapServers()); + } + } + + @Test + void testUsageWithListener() throws Exception { + try ( + Network network = Network.newNetwork(); + // registerListener { + KafkaContainer kafka = new KafkaContainer("apache/kafka-native:3.8.0") + .withListener("kafka:19092") + .withNetwork(network); + // } + KCatContainer kcat = new KCatContainer().withNetwork(network) + ) { + kafka.start(); + kcat.start(); + + kcat.execInContainer("kcat", "-b", "kafka:19092", "-t", "msgs", "-P", "-l", "/data/msgs.txt"); + String stdout = kcat + .execInContainer("kcat", "-b", "kafka:19092", "-C", "-t", "msgs", "-c", "1") + .getStdout(); + + assertThat(stdout).contains("Message produced by kcat"); + } + } + + @Test + void testUsageWithListenerFromProxy() throws Exception { + try ( + Network network = Network.newNetwork(); + SocatContainer socat = new SocatContainer().withNetwork(network).withTarget(2000, "kafka", 19092); + KafkaContainer kafka = new KafkaContainer("apache/kafka-native:3.8.0") + .withListener("kafka:19092", () -> socat.getHost() + ":" + socat.getMappedPort(2000)) + .withNetwork(network) + ) { + socat.start(); + kafka.start(); + + String bootstrapServers = String.format("%s:%s", socat.getHost(), socat.getMappedPort(2000)); + testKafkaFunctionality(bootstrapServers); + } + } +} diff --git a/modules/kafka/src/test/resources/kafka_server_jaas.conf b/modules/kafka/src/test/resources/kafka_server_jaas.conf new file mode 100644 index 00000000000..89c88ed0522 --- /dev/null +++ b/modules/kafka/src/test/resources/kafka_server_jaas.conf @@ -0,0 +1,5 @@ +KafkaServer { + org.apache.kafka.common.security.scram.ScramLoginModule required + username="admin" + password="admin"; +}; diff --git a/modules/kafka/src/test/resources/logback-test.xml b/modules/kafka/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/kafka/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/ldap/build.gradle b/modules/ldap/build.gradle new file mode 100644 index 00000000000..7e79be920c8 --- /dev/null +++ b/modules/ldap/build.gradle @@ -0,0 +1,7 @@ +description = "Testcontainers :: LDAP" + +dependencies { + api project(':testcontainers') + + testImplementation 'com.unboundid:unboundid-ldapsdk:7.0.5' +} diff --git a/modules/ldap/src/main/java/org/testcontainers/ldap/LLdapContainer.java b/modules/ldap/src/main/java/org/testcontainers/ldap/LLdapContainer.java new file mode 100644 index 00000000000..c811bffa1ec --- /dev/null +++ b/modules/ldap/src/main/java/org/testcontainers/ldap/LLdapContainer.java @@ -0,0 +1,90 @@ +package org.testcontainers.ldap; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for LLDAP. + *

+ * Supported image: {@code lldap/lldap} + *

+ * Exposed ports: + *

    + *
  • LDAP: 3890
  • + *
  • UI: 17170
  • + *
+ */ +@Slf4j +public class LLdapContainer extends GenericContainer { + + private static final String IMAGE_VERSION = "lldap/lldap"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse(IMAGE_VERSION); + + private static final int LDAP_PORT = 3890; + + private static final int LDAPS_PORT = 6360; + + private static final int UI_PORT = 17170; + + public LLdapContainer(String image) { + this(DockerImageName.parse(image)); + } + + public LLdapContainer(DockerImageName image) { + super(image); + image.assertCompatibleWith(DEFAULT_IMAGE_NAME); + addExposedPorts(LDAP_PORT, UI_PORT); + + waitingFor(Wait.forHttp("/health").forPort(UI_PORT).forStatusCode(200)); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + log.info("LLDAP container is ready! UI available at http://{}:{}", getHost(), getMappedPort(UI_PORT)); + } + + public LLdapContainer withBaseDn(String baseDn) { + withEnv("LLDAP_LDAP_BASE_DN", baseDn); + return this; + } + + public LLdapContainer withUserPass(String userPass) { + withEnv("LLDAP_LDAP_USER_PASS", userPass); + return this; + } + + public int getLdapPort() { + int port = getEnvMap().getOrDefault("LLDAP_LDAPS_OPTIONS__ENABLED", "false").equals("true") + ? LDAPS_PORT + : LDAP_PORT; + return getMappedPort(port); + } + + public String getLdapUrl() { + String protocol = getEnvMap().getOrDefault("LLDAP_LDAPS_OPTIONS__ENABLED", "false").equals("true") + ? "ldaps" + : "ldap"; + return String.format("%s://%s:%d", protocol, getHost(), getLdapPort()); + } + + public String getBaseDn() { + return getEnvMap().getOrDefault("LLDAP_LDAP_BASE_DN", "dc=example,dc=com"); + } + + public String getUser() { + return String.format("cn=admin,ou=people,%s", getBaseDn()); + } + + @Deprecated + public String getUserPass() { + return getEnvMap().getOrDefault("LLDAP_LDAP_USER_PASS", "password"); + } + + public String getPassword() { + return getEnvMap().getOrDefault("LLDAP_LDAP_USER_PASS", "password"); + } +} diff --git a/modules/ldap/src/test/java/org/testcontainers/ldap/LLdapContainerTest.java b/modules/ldap/src/test/java/org/testcontainers/ldap/LLdapContainerTest.java new file mode 100644 index 00000000000..96e77c9a3f0 --- /dev/null +++ b/modules/ldap/src/test/java/org/testcontainers/ldap/LLdapContainerTest.java @@ -0,0 +1,66 @@ +package org.testcontainers.ldap; + +import com.unboundid.ldap.sdk.BindResult; +import com.unboundid.ldap.sdk.LDAPConnection; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.LDAPURL; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LLdapContainerTest { + + @Test + void test() throws LDAPException { + try ( // container { + LLdapContainer lldap = new LLdapContainer("lldap/lldap:v0.6.1-alpine") + // } + ) { + lldap.start(); + LDAPConnection connection = new LDAPConnection(lldap.getHost(), lldap.getLdapPort()); + BindResult result = connection.bind(lldap.getUser(), lldap.getPassword()); + assertThat(result).isNotNull(); + } + } + + @Test + void testUsingLdapUrl() throws LDAPException { + try (LLdapContainer lldap = new LLdapContainer("lldap/lldap:v0.6.1-alpine")) { + lldap.start(); + + LDAPURL ldapUrl = new LDAPURL(lldap.getLdapUrl()); + LDAPConnection connection = new LDAPConnection(ldapUrl.getHost(), ldapUrl.getPort()); + BindResult result = connection.bind(lldap.getUser(), lldap.getPassword()); + assertThat(result).isNotNull(); + } + } + + @Test + void testWithCustomBaseDn() throws LDAPException { + try ( + LLdapContainer lldap = new LLdapContainer("lldap/lldap:v0.6.1-alpine") + .withBaseDn("dc=testcontainers,dc=org") + ) { + lldap.start(); + + assertThat(lldap.getBaseDn()).isEqualTo("dc=testcontainers,dc=org"); + + LDAPURL ldapUrl = new LDAPURL(lldap.getLdapUrl()); + LDAPConnection connection = new LDAPConnection(ldapUrl.getHost(), ldapUrl.getPort()); + BindResult result = connection.bind(lldap.getUser(), lldap.getPassword()); + assertThat(result).isNotNull(); + } + } + + @Test + void testWithCustomUserPass() throws LDAPException { + try (LLdapContainer lldap = new LLdapContainer("lldap/lldap:v0.6.1-alpine").withUserPass("adminPas$word")) { + lldap.start(); + + LDAPURL ldapUrl = new LDAPURL(lldap.getLdapUrl()); + LDAPConnection connection = new LDAPConnection(ldapUrl.getHost(), ldapUrl.getPort()); + BindResult result = connection.bind(lldap.getUser(), lldap.getPassword()); + assertThat(result).isNotNull(); + } + } +} diff --git a/modules/ldap/src/test/resources/logback-test.xml b/modules/ldap/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/ldap/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/localstack/build.gradle b/modules/localstack/build.gradle new file mode 100644 index 00000000000..eca4aeaa27a --- /dev/null +++ b/modules/localstack/build.gradle @@ -0,0 +1,12 @@ +description = "Testcontainers :: Localstack" + +dependencies { + api project(':testcontainers') + + testImplementation platform("software.amazon.awssdk:bom:2.46.20") + testImplementation 'software.amazon.awssdk:s3' + testImplementation 'software.amazon.awssdk:sqs' + testImplementation 'software.amazon.awssdk:cloudwatchlogs' + testImplementation 'software.amazon.awssdk:lambda' + testImplementation 'software.amazon.awssdk:kms' +} diff --git a/modules/localstack/src/main/java/org/testcontainers/containers/localstack/LocalStackContainer.java b/modules/localstack/src/main/java/org/testcontainers/containers/localstack/LocalStackContainer.java new file mode 100644 index 00000000000..ce50313d429 --- /dev/null +++ b/modules/localstack/src/main/java/org/testcontainers/containers/localstack/LocalStackContainer.java @@ -0,0 +1,476 @@ +package org.testcontainers.containers.localstack; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.rnorth.ducttape.Preconditions; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; + +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Testcontainers implementation for LocalStack. + *

+ * Supported images: {@code localstack/localstack}, {@code localstack/localstack-pro} + *

+ * Exposed ports: 4566 + * + * @deprecated use {@link org.testcontainers.localstack.LocalStackContainer} instead. + */ +@Slf4j +@Deprecated +public class LocalStackContainer extends GenericContainer { + + static final int PORT = 4566; + + @Deprecated + private static final String HOSTNAME_EXTERNAL_ENV_VAR = "HOSTNAME_EXTERNAL"; + + private static final String LOCALSTACK_HOST_ENV_VAR = "LOCALSTACK_HOST"; + + private final List services = new ArrayList<>(); + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("localstack/localstack"); + + private static final DockerImageName LOCALSTACK_PRO_IMAGE_NAME = DockerImageName.parse("localstack/localstack-pro"); + + private static final String DEFAULT_TAG = "0.11.2"; + + private static final String DEFAULT_REGION = "us-east-1"; + + private static final String DEFAULT_AWS_ACCESS_KEY_ID = "test"; + + private static final String DEFAULT_AWS_SECRET_ACCESS_KEY = "test"; + + private static final String STARTER_SCRIPT = "/testcontainers_start.sh"; + + @Deprecated + public static final String VERSION = DEFAULT_TAG; + + /** + * Whether or to assume that all APIs run on different ports (when true) or are + * exposed on a single port (false). From the Localstack README: + * + *

Note: Starting with version 0.11.0, all APIs are exposed via a single edge + * service [...] The API-specific endpoints below are still left for backward-compatibility but + * may get removed in a future release - please reconfigure your client SDKs to start using the + * single edge endpoint URL!
+ *

+ * Testcontainers will use the tag of the docker image to infer whether or not the used version + * of Localstack supports this feature. + */ + private final boolean legacyMode; + + /** + * Starting with version 0.13.0, setting services list on Localstack is not required. When false, + * containers are started lazily. When true, container fails to start if services list is not provided. + * + * Testcontainers will use the tag of the docker image to infer whether or not the used version + * of Localstack required services list. + */ + private final boolean servicesEnvVarRequired; + + private final boolean isVersion2; + + /** + * @deprecated use {@link #LocalStackContainer(DockerImageName)} instead + */ + @Deprecated + public LocalStackContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + /** + * @deprecated use {@link #LocalStackContainer(DockerImageName)} instead + */ + @Deprecated + public LocalStackContainer(String version) { + this(DEFAULT_IMAGE_NAME.withTag(version)); + } + + /** + * @param dockerImageName image name to use for Localstack + */ + public LocalStackContainer(final DockerImageName dockerImageName) { + this(dockerImageName, shouldRunInLegacyMode(dockerImageName.getVersionPart())); + } + + /** + * @param dockerImageName image name to use for Localstack + * @param useLegacyMode if true, each AWS service is exposed on a different port + * @deprecated use {@link #LocalStackContainer(DockerImageName)} instead + */ + @Deprecated + public LocalStackContainer(final DockerImageName dockerImageName, boolean useLegacyMode) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, LOCALSTACK_PRO_IMAGE_NAME); + + this.legacyMode = useLegacyMode; + String version = dockerImageName.getVersionPart(); + this.servicesEnvVarRequired = isServicesEnvVarRequired(version); + this.isVersion2 = isVersion2(version); + + withFileSystemBind(DockerClientFactory.instance().getRemoteDockerUnixSocketPath(), "/var/run/docker.sock"); + waitingFor(Wait.forLogMessage(".*Ready\\.\n", 1)); + withCreateContainerCmdModifier(cmd -> { + cmd.withEntrypoint( + "sh", + "-c", + "while [ ! -f " + STARTER_SCRIPT + " ]; do sleep 0.1; done; " + STARTER_SCRIPT + ); + }); + } + + private static boolean isVersion2(String version) { + if (version.equals("latest")) { + return true; + } + + ComparableVersion comparableVersion = new ComparableVersion(version); + return comparableVersion.isGreaterThanOrEqualTo("2.0.0"); + } + + private static boolean isServicesEnvVarRequired(String version) { + if (version.equals("latest")) { + return false; + } + + ComparableVersion comparableVersion = new ComparableVersion(version); + if (comparableVersion.isSemanticVersion()) { + return comparableVersion.isLessThan("0.13"); + } + + log.warn("Version {} is not a semantic version, services list is required.", version); + + return true; + } + + static boolean shouldRunInLegacyMode(String version) { + // assume that the latest images are up-to-date + // also consider images with extra packages (like latest-bigdata) and service-specific images (like s3-latest) + if (version.equals("latest") || version.startsWith("latest-") || version.endsWith("-latest")) { + return false; + } + + ComparableVersion comparableVersion = new ComparableVersion(version); + if (comparableVersion.isSemanticVersion()) { + boolean versionRequiresLegacyMode = comparableVersion.isLessThan("0.11"); + return versionRequiresLegacyMode; + } + + log.warn("Version {} is not a semantic version, LocalStack will run in legacy mode.", version); + log.warn( + "Consider using \"LocalStackContainer(DockerImageName dockerImageName, boolean legacyMode)\" constructor if you want to disable legacy mode." + ); + return true; + } + + @Override + protected void configure() { + super.configure(); + + if (this.servicesEnvVarRequired) { + Preconditions.check("services list must not be empty", !services.isEmpty()); + } + + if (!services.isEmpty()) { + withEnv("SERVICES", services.stream().map(EnabledService::getName).collect(Collectors.joining(","))); + if (this.servicesEnvVarRequired) { + withEnv("EAGER_SERVICE_LOADING", "1"); + } + } + + if (this.isVersion2) { + resolveHostname(LOCALSTACK_HOST_ENV_VAR); + } else { + resolveHostname(HOSTNAME_EXTERNAL_ENV_VAR); + } + + exposePorts(); + } + + @Override + protected void containerIsStarting(InspectContainerResponse containerInfo) { + String command = "#!/bin/bash\n"; + command += "export LAMBDA_DOCKER_FLAGS=" + configureServiceContainerLabels("LAMBDA_DOCKER_FLAGS") + "\n"; + command += "export ECS_DOCKER_FLAGS=" + configureServiceContainerLabels("ECS_DOCKER_FLAGS") + "\n"; + command += "export EC2_DOCKER_FLAGS=" + configureServiceContainerLabels("EC2_DOCKER_FLAGS") + "\n"; + command += "export BATCH_DOCKER_FLAGS=" + configureServiceContainerLabels("BATCH_DOCKER_FLAGS") + "\n"; + command += "/usr/local/bin/docker-entrypoint.sh\n"; + copyFileToContainer(Transferable.of(command, 0777), STARTER_SCRIPT); + } + + /** + * Configure the LocalStack container to include the default testcontainers labels on all spawned lambda containers + * Necessary to properly clean up lambda containers even if the LocalStack container is killed before it gets the + * chance. + * @return the lambda container labels as a string + */ + private String configureServiceContainerLabels(String existingEnvFlagKey) { + String internalMarkerFlags = internalMarkerLabels(); + String existingFlags = getEnvMap().get(existingEnvFlagKey); + if (existingFlags != null) { + internalMarkerFlags = existingFlags + " " + internalMarkerFlags; + } + return "\"" + internalMarkerFlags + "\""; + } + + /** + * Provides a docker argument string including all default labels set on testcontainers containers (excluding reuse labels) + * @return Argument string in the format `-l key1=value1 -l key2=value2` + */ + private String internalMarkerLabels() { + return getContainerInfo() + .getConfig() + .getLabels() + .entrySet() + .stream() + .filter(entry -> entry.getKey().startsWith(DockerClientFactory.TESTCONTAINERS_LABEL)) + .filter(entry -> { + return ( + !entry.getKey().equals("org.testcontainers.hash") && + !entry.getKey().equals("org.testcontainers.copied_files.hash") + ); + }) + .map(entry -> String.format("-l %s=%s", entry.getKey(), entry.getValue())) + .collect(Collectors.joining(" ")); + } + + private void resolveHostname(String envVar) { + String hostnameExternalReason; + if (getEnvMap().containsKey(envVar)) { + // do nothing + hostnameExternalReason = "explicitly as environment variable"; + } else if (getNetwork() != null && getNetworkAliases() != null && getNetworkAliases().size() >= 1) { + withEnv(envVar, getNetworkAliases().get(getNetworkAliases().size() - 1)); // use the last network alias set + hostnameExternalReason = "to match last network alias on container with non-default network"; + } else { + withEnv(envVar, getHost()); + hostnameExternalReason = "to match host-routable address for container"; + } + + logger() + .info("{} environment variable set to {} ({})", envVar, getEnvMap().get(envVar), hostnameExternalReason); + } + + private void exposePorts() { + if (legacyMode) { + services.stream().map(this::getServicePort).distinct().forEach(this::addExposedPort); + } else { + this.addExposedPort(PORT); + } + } + + public LocalStackContainer withServices(Service... services) { + this.services.addAll(Arrays.asList(services)); + return self(); + } + + /** + * Declare a set of simulated AWS services that should be launched by this container. + * @param services one or more service names + * @return this container object + */ + public LocalStackContainer withServices(EnabledService... services) { + this.services.addAll(Arrays.asList(services)); + return self(); + } + + public URI getEndpointOverride(Service service) { + return getEndpointOverride((EnabledService) service); + } + + /** + * Provides an endpoint override that is preconfigured to communicate with a given simulated service. + * The provided endpoint override should be set in the AWS Java SDK v2 when building a client, e.g.: + *

S3Client s3 = S3Client
+             .builder()
+             .endpointOverride(localstack.getEndpointOverride(LocalStackContainer.Service.S3))
+             .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(
+             localstack.getAccessKey(), localstack.getSecretKey()
+             )))
+             .region(Region.of(localstack.getRegion()))
+             .build()
+             
+ *

Please note that this method is only intended to be used for configuring AWS SDK clients + * that are running on the test host. If other containers need to call this one, they should be configured + * specifically to do so using a Docker network and appropriate addressing.

+ * + * @param service the service that is to be accessed + * @return an {@link URI} endpoint override + */ + public URI getEndpointOverride(EnabledService service) { + try { + final String address = getHost(); + String ipAddress = address; + // resolve IP address and use that as the endpoint so that path-style access is automatically used for S3 + ipAddress = InetAddress.getByName(address).getHostAddress(); + return new URI("http://" + ipAddress + ":" + getMappedPort(getServicePort(service))); + } catch (UnknownHostException | URISyntaxException e) { + throw new IllegalStateException("Cannot obtain endpoint URL", e); + } + } + + /** + * Provides an endpoint to communicate with LocalStack service. + * The provided endpoint should be set in the AWS Java SDK v2 when building a client, e.g.: + *
S3Client s3 = S3Client
+             .builder()
+             .endpointOverride(localstack.getEndpoint())
+             .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(
+             localstack.getAccessKey(), localstack.getSecretKey()
+             )))
+             .region(Region.of(localstack.getRegion()))
+             .build()
+             
+ *

Please note that this method is only intended to be used for configuring AWS SDK clients + * that are running on the test host. If other containers need to call this one, they should be configured + * specifically to do so using a Docker network and appropriate addressing.

+ * + * @return an {@link URI} endpoint + */ + public URI getEndpoint() { + try { + final String address = getHost(); + // resolve IP address and use that as the endpoint so that path-style access is automatically used for S3 + String ipAddress = InetAddress.getByName(address).getHostAddress(); + return new URI("http://" + ipAddress + ":" + getMappedPort(PORT)); + } catch (UnknownHostException | URISyntaxException e) { + throw new IllegalStateException("Cannot obtain endpoint URL", e); + } + } + + private int getServicePort(EnabledService service) { + return legacyMode ? service.getPort() : PORT; + } + + /** + * Provides a default access key that is preconfigured to communicate with a given simulated service. + * AWS Access Key + * The access key can be used to construct AWS SDK v2 clients: + *
S3Client s3 = S3Client
+             .builder()
+             .endpointOverride(localstack.getEndpointOverride(LocalStackContainer.Service.S3))
+             .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(
+             localstack.getAccessKey(), localstack.getSecretKey()
+             )))
+             .region(Region.of(localstack.getRegion()))
+             .build()
+     
+ * @return a default access key + */ + public String getAccessKey() { + return this.getEnvMap().getOrDefault("AWS_ACCESS_KEY_ID", DEFAULT_AWS_ACCESS_KEY_ID); + } + + /** + * Provides a default secret key that is preconfigured to communicate with a given simulated service. + * AWS Secret Key + * The secret key can be used to construct AWS SDK v2 clients: + *
S3Client s3 = S3Client
+             .builder()
+             .endpointOverride(localstack.getEndpointOverride(LocalStackContainer.Service.S3))
+             .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(
+             localstack.getAccessKey(), localstack.getSecretKey()
+             )))
+             .region(Region.of(localstack.getRegion()))
+             .build()
+     
+ * @return a default secret key + */ + public String getSecretKey() { + return this.getEnvMap().getOrDefault("AWS_SECRET_ACCESS_KEY", DEFAULT_AWS_SECRET_ACCESS_KEY); + } + + /** + * Provides a default region that is preconfigured to communicate with a given simulated service. + * The region can be used to construct AWS SDK v2 clients: + *
S3Client s3 = S3Client
+             .builder()
+             .endpointOverride(localstack.getEndpointOverride(LocalStackContainer.Service.S3))
+             .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(
+             localstack.getAccessKey(), localstack.getSecretKey()
+             )))
+             .region(Region.of(localstack.getRegion()))
+             .build()
+     
+ * @return a default region + */ + public String getRegion() { + return this.getEnvMap().getOrDefault("DEFAULT_REGION", DEFAULT_REGION); + } + + public interface EnabledService { + static EnabledService named(String name) { + return () -> name; + } + + String getName(); + + default int getPort() { + return PORT; + } + } + + @RequiredArgsConstructor + @Getter + @FieldDefaults(makeFinal = true) + public enum Service implements EnabledService { + API_GATEWAY("apigateway", 4567), + EC2("ec2", 4597), + KINESIS("kinesis", 4568), + DYNAMODB("dynamodb", 4569), + DYNAMODB_STREAMS("dynamodbstreams", 4570), + // TODO: Clarify usage for ELASTICSEARCH and ELASTICSEARCH_SERVICE + // ELASTICSEARCH("es", 4571), + S3("s3", 4572), + FIREHOSE("firehose", 4573), + LAMBDA("lambda", 4574), + SNS("sns", 4575), + SQS("sqs", 4576), + REDSHIFT("redshift", 4577), + // ELASTICSEARCH_SERVICE("", 4578), + SES("ses", 4579), + ROUTE53("route53", 4580), + CLOUDFORMATION("cloudformation", 4581), + CLOUDWATCH("cloudwatch", 4582), + SSM("ssm", 4583), + SECRETSMANAGER("secretsmanager", 4584), + STEPFUNCTIONS("stepfunctions", 4585), + CLOUDWATCHLOGS("logs", 4586), + STS("sts", 4592), + IAM("iam", 4593), + KMS("kms", 4599); + + String localStackName; + + int port; + + @Override + public String getName() { + return localStackName; + } + + @Deprecated + /* + Since version 0.11, LocalStack exposes all services on a single (4566) port. + */ + public int getPort() { + return port; + } + } +} diff --git a/modules/localstack/src/main/java/org/testcontainers/localstack/LocalStackContainer.java b/modules/localstack/src/main/java/org/testcontainers/localstack/LocalStackContainer.java new file mode 100644 index 00000000000..31eee4ca5ca --- /dev/null +++ b/modules/localstack/src/main/java/org/testcontainers/localstack/LocalStackContainer.java @@ -0,0 +1,220 @@ +package org.testcontainers.localstack; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Testcontainers implementation for LocalStack. + *

+ * Supported images: {@code localstack/localstack}, {@code localstack/localstack-pro} + *

+ * Exposed ports: 4566 + */ +@Slf4j +public class LocalStackContainer extends GenericContainer { + + static final int PORT = 4566; + + private final List services = new ArrayList<>(); + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("localstack/localstack"); + + private static final DockerImageName LOCALSTACK_PRO_IMAGE_NAME = DockerImageName.parse("localstack/localstack-pro"); + + private static final String DEFAULT_REGION = "us-east-1"; + + private static final String DEFAULT_AWS_ACCESS_KEY_ID = "test"; + + private static final String DEFAULT_AWS_SECRET_ACCESS_KEY = "test"; + + private static final String STARTER_SCRIPT = "/testcontainers_start.sh"; + + /** + * @param dockerImageName image name to use for Localstack + */ + public LocalStackContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + /** + * @param dockerImageName image name to use for Localstack + */ + public LocalStackContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, LOCALSTACK_PRO_IMAGE_NAME); + + withExposedPorts(PORT); + withFileSystemBind(DockerClientFactory.instance().getRemoteDockerUnixSocketPath(), "/var/run/docker.sock"); + waitingFor(Wait.forLogMessage(".*Ready\\.\n", 1)); + withCreateContainerCmdModifier(cmd -> { + cmd.withEntrypoint( + "sh", + "-c", + "while [ ! -f " + STARTER_SCRIPT + " ]; do sleep 0.1; done; " + STARTER_SCRIPT + ); + }); + } + + @Override + protected void configure() { + if (!services.isEmpty()) { + withEnv("SERVICES", String.join(",", this.services)); + } + } + + @Override + protected void containerIsStarting(InspectContainerResponse containerInfo) { + String command = "#!/bin/bash\n"; + command += "export LAMBDA_DOCKER_FLAGS=" + configureServiceContainerLabels("LAMBDA_DOCKER_FLAGS") + "\n"; + command += "export ECS_DOCKER_FLAGS=" + configureServiceContainerLabels("ECS_DOCKER_FLAGS") + "\n"; + command += "export EC2_DOCKER_FLAGS=" + configureServiceContainerLabels("EC2_DOCKER_FLAGS") + "\n"; + command += "export BATCH_DOCKER_FLAGS=" + configureServiceContainerLabels("BATCH_DOCKER_FLAGS") + "\n"; + command += "/usr/local/bin/docker-entrypoint.sh\n"; + copyFileToContainer(Transferable.of(command, 0777), STARTER_SCRIPT); + } + + /** + * Configure the LocalStack container to include the default testcontainers labels on all spawned lambda containers + * Necessary to properly clean up lambda containers even if the LocalStack container is killed before it gets the + * chance. + * @return the lambda container labels as a string + */ + private String configureServiceContainerLabels(String existingEnvFlagKey) { + String internalMarkerFlags = internalMarkerLabels(); + String existingFlags = getEnvMap().get(existingEnvFlagKey); + if (existingFlags != null) { + internalMarkerFlags = existingFlags + " " + internalMarkerFlags; + } + return "\"" + internalMarkerFlags + "\""; + } + + /** + * Provides a docker argument string including all default labels set on testcontainers containers (excluding reuse labels) + * @return Argument string in the format `-l key1=value1 -l key2=value2` + */ + private String internalMarkerLabels() { + return getContainerInfo() + .getConfig() + .getLabels() + .entrySet() + .stream() + .filter(entry -> entry.getKey().startsWith(DockerClientFactory.TESTCONTAINERS_LABEL)) + .filter(entry -> { + return ( + !entry.getKey().equals("org.testcontainers.hash") && + !entry.getKey().equals("org.testcontainers.copied_files.hash") + ); + }) + .map(entry -> String.format("-l %s=%s", entry.getKey(), entry.getValue())) + .collect(Collectors.joining(" ")); + } + + /** + * Declare a set of simulated AWS services that should be launched by this container. + * @param services one or more service names + * @return this container object + */ + public LocalStackContainer withServices(String... services) { + this.services.addAll(Arrays.asList(services)); + return self(); + } + + /** + * Provides an endpoint to communicate with LocalStack service. + * The provided endpoint should be set in the AWS Java SDK v2 when building a client, e.g.: + *

S3Client s3 = S3Client
+             .builder()
+             .endpointOverride(localstack.getEndpoint())
+             .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(
+             localstack.getAccessKey(), localstack.getSecretKey()
+             )))
+             .region(Region.of(localstack.getRegion()))
+             .build()
+             
+ *

Please note that this method is only intended to be used for configuring AWS SDK clients + * that are running on the test host. If other containers need to call this one, they should be configured + * specifically to do so using a Docker network and appropriate addressing.

+ * + * @return an {@link URI} endpoint + */ + public URI getEndpoint() { + try { + final String address = getHost(); + // resolve IP address and use that as the endpoint so that path-style access is automatically used for S3 + String ipAddress = InetAddress.getByName(address).getHostAddress(); + return new URI("http://" + ipAddress + ":" + getMappedPort(PORT)); + } catch (UnknownHostException | URISyntaxException e) { + throw new IllegalStateException("Cannot obtain endpoint URL", e); + } + } + + /** + * Provides a default access key that is preconfigured to communicate with a given simulated service. + * AWS Access Key + * The access key can be used to construct AWS SDK v2 clients: + *
S3Client s3 = S3Client
+             .builder()
+             .endpointOverride(localstack.getEndpoint())
+             .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(
+             localstack.getAccessKey(), localstack.getSecretKey()
+             )))
+             .region(Region.of(localstack.getRegion()))
+             .build()
+     
+ * @return a default access key + */ + public String getAccessKey() { + return this.getEnvMap().getOrDefault("AWS_ACCESS_KEY_ID", DEFAULT_AWS_ACCESS_KEY_ID); + } + + /** + * Provides a default secret key that is preconfigured to communicate with a given simulated service. + * AWS Secret Key + * The secret key can be used to construct AWS SDK v2 clients: + *
S3Client s3 = S3Client
+             .builder()
+             .endpointOverride(localstack.getEndpoint())
+             .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(
+             localstack.getAccessKey(), localstack.getSecretKey()
+             )))
+             .region(Region.of(localstack.getRegion()))
+             .build()
+     
+ * @return a default secret key + */ + public String getSecretKey() { + return this.getEnvMap().getOrDefault("AWS_SECRET_ACCESS_KEY", DEFAULT_AWS_SECRET_ACCESS_KEY); + } + + /** + * Provides a default region that is preconfigured to communicate with a given simulated service. + * The region can be used to construct AWS SDK v2 clients: + *
S3Client s3 = S3Client
+             .builder()
+             .endpointOverride(localstack.getEndpoint())
+             .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(
+             localstack.getAccessKey(), localstack.getSecretKey()
+             )))
+             .region(Region.of(localstack.getRegion()))
+             .build()
+     
+ * @return a default region + */ + public String getRegion() { + return this.getEnvMap().getOrDefault("DEFAULT_REGION", DEFAULT_REGION); + } +} diff --git a/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LegacyModeTest.java b/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LegacyModeTest.java new file mode 100644 index 00000000000..b2537d70b77 --- /dev/null +++ b/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LegacyModeTest.java @@ -0,0 +1,118 @@ +package org.testcontainers.containers.localstack; + +import com.github.dockerjava.api.DockerClient; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.localstack.LocalStackContainer.Service; +import org.testcontainers.images.RemoteDockerImage; +import org.testcontainers.utility.DockerImageName; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class LegacyModeTest { + + private static final DockerImageName LOCALSTACK_CUSTOM_TAG = DockerImageName + .parse("localstack/localstack:0.12.8") + .withTag("custom"); + + @BeforeAll + static void setup() { + DockerClient dockerClient = DockerClientFactory.instance().client(); + dockerClient + .tagImageCmd( + new RemoteDockerImage(LocalstackTestImages.LOCALSTACK_0_12_IMAGE).get(), + LOCALSTACK_CUSTOM_TAG.getRepository(), + LOCALSTACK_CUSTOM_TAG.getVersionPart() + ) + .exec(); + } + + static Stream localstackVersionWithLegacyOff() { + return Stream.of( + Arguments.arguments("0.12", new LocalStackContainer(LocalstackTestImages.LOCALSTACK_0_12_IMAGE)), + Arguments.arguments("0.11", new LocalStackContainer(LocalstackTestImages.LOCALSTACK_0_11_IMAGE)), + Arguments.arguments( + "0.11 with legacy = off", + new LocalStackContainer(LocalstackTestImages.LOCALSTACK_0_11_IMAGE, false) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("localstackVersionWithLegacyOff") + void samePortIsExposedForAllServices(String description, LocalStackContainer localstack) { + localstack.withServices(Service.S3, Service.SQS); + localstack.start(); + + try { + assertThat(localstack.getExposedPorts()).as("A single port is exposed").hasSize(1); + assertThat(localstack.getEndpointOverride(Service.SQS).toString()) + .as("Endpoint overrides are different") + .isEqualTo(localstack.getEndpointOverride(Service.S3).toString()); + assertThat(localstack.getEndpointOverride(Service.SQS).toString()) + .as("Endpoint configuration have different endpoints") + .isEqualTo(localstack.getEndpointOverride(Service.S3).toString()); + } finally { + localstack.stop(); + } + } + + public static Stream localstackVersionWithLegacyOn() { + return Stream.of( + Arguments.arguments("0.10", new LocalStackContainer(LocalstackTestImages.LOCALSTACK_0_10_IMAGE)), + Arguments.arguments("custom", new LocalStackContainer(LOCALSTACK_CUSTOM_TAG)), + Arguments.arguments( + "0.11 with legacy = on", + new LocalStackContainer(LocalstackTestImages.LOCALSTACK_0_11_IMAGE, true) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("localstackVersionWithLegacyOn") + void differentPortsAreExposed(String description, LocalStackContainer localstack) { + localstack.withServices(Service.S3, Service.SQS); + localstack.start(); + + try { + assertThat(localstack.getExposedPorts()).as("Multiple ports are exposed").hasSizeGreaterThan(1); + assertThat(localstack.getEndpointOverride(Service.SQS).toString()) + .as("Endpoint overrides are different") + .isNotEqualTo(localstack.getEndpointOverride(Service.S3).toString()); + assertThat(localstack.getEndpointOverride(Service.SQS).toString()) + .as("Endpoint configuration have different endpoints") + .isNotEqualTo(localstack.getEndpointOverride(Service.S3).toString()); + } finally { + localstack.stop(); + } + } + + public static Stream constructors() { + return Stream.of( + Arguments.arguments("latest", false), + Arguments.arguments("s3-latest", false), + Arguments.arguments("latest-bigdata", false), + Arguments.arguments("3.4.0-bigdata", false), + Arguments.arguments("3.4.0@sha256:54fcf172f6ff70909e1e26652c3bb4587282890aff0d02c20aa7695469476ac0", false), + Arguments.arguments("1.4@sha256:7badf31c550f81151c485980e17542592942d7f05acc09723c5f276d41b5927d", false), + Arguments.arguments("3.4.0", false), + Arguments.arguments("0.12", false), + Arguments.arguments("0.11", false), + Arguments.arguments("sha256:8bf0d744fea26603f2b11ef7206edb38375ef954258afaeda96532a6c9c1ab8b", false), + Arguments.arguments("0.10.7@sha256:45ef287e29af7285c6e4013fafea1e3567c167cd22d12282f0a5f9c7894b1c5f", true), + Arguments.arguments("0.10.7", true), + Arguments.arguments("0.9.6", true) + ); + } + + @ParameterizedTest + @MethodSource("constructors") + void testLegacyMode(String version, boolean shouldUseLegacyMode) { + assertThat(LocalStackContainer.shouldRunInLegacyMode(version)).isEqualTo(shouldUseLegacyMode); + } +} diff --git a/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackTestImages.java b/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackTestImages.java new file mode 100644 index 00000000000..9ca7dd6614f --- /dev/null +++ b/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackTestImages.java @@ -0,0 +1,15 @@ +package org.testcontainers.containers.localstack; + +import org.testcontainers.utility.DockerImageName; + +public interface LocalstackTestImages { + DockerImageName LOCALSTACK_IMAGE = DockerImageName.parse("localstack/localstack:4.9.2"); + + DockerImageName LOCALSTACK_0_10_IMAGE = LOCALSTACK_IMAGE.withTag("0.10.7"); + + DockerImageName LOCALSTACK_0_11_IMAGE = LOCALSTACK_IMAGE.withTag("0.11.3"); + + DockerImageName LOCALSTACK_0_12_IMAGE = LOCALSTACK_IMAGE.withTag("0.12.8"); + + DockerImageName AWS_CLI_IMAGE = DockerImageName.parse("amazon/aws-cli:2.7.27"); +} diff --git a/modules/localstack/src/test/java/org/testcontainers/localstack/LocalStackContainerTest.java b/modules/localstack/src/test/java/org/testcontainers/localstack/LocalStackContainerTest.java new file mode 100644 index 00000000000..f3fa6a07457 --- /dev/null +++ b/modules/localstack/src/test/java/org/testcontainers/localstack/LocalStackContainerTest.java @@ -0,0 +1,512 @@ +package org.testcontainers.localstack; + +import com.github.dockerjava.api.DockerClient; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.localstack.LocalstackTestImages; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient; +import software.amazon.awssdk.services.cloudwatchlogs.model.CreateLogGroupRequest; +import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsResponse; +import software.amazon.awssdk.services.kms.KmsClient; +import software.amazon.awssdk.services.kms.model.CreateKeyRequest; +import software.amazon.awssdk.services.kms.model.CreateKeyResponse; +import software.amazon.awssdk.services.kms.model.Tag; +import software.amazon.awssdk.services.lambda.LambdaClient; +import software.amazon.awssdk.services.lambda.model.CreateFunctionRequest; +import software.amazon.awssdk.services.lambda.model.CreateFunctionResponse; +import software.amazon.awssdk.services.lambda.model.FunctionCode; +import software.amazon.awssdk.services.lambda.model.GetFunctionConfigurationRequest; +import software.amazon.awssdk.services.lambda.model.InvokeRequest; +import software.amazon.awssdk.services.lambda.model.InvokeResponse; +import software.amazon.awssdk.services.lambda.model.Runtime; +import software.amazon.awssdk.services.lambda.waiters.LambdaWaiter; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.Bucket; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.sqs.SqsClient; +import software.amazon.awssdk.services.sqs.model.CreateQueueRequest; +import software.amazon.awssdk.services.sqs.model.CreateQueueResponse; +import software.amazon.awssdk.services.sqs.model.ReceiveMessageRequest; +import software.amazon.awssdk.services.sqs.model.SendMessageRequest; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; + +@Slf4j +class LocalStackContainerTest { + + @Nested + class WithoutNetwork { + + @Test + void s3TestOverBridgeNetwork() { + try ( + // container { + LocalStackContainer localstack = new LocalStackContainer(LocalstackTestImages.LOCALSTACK_IMAGE) + .withServices("s3") + // } + ) { + localstack.start(); + + // with_aws_sdk_v2 { + S3Client s3 = S3Client + .builder() + .endpointOverride(localstack.getEndpoint()) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()) + ) + ) + .region(Region.of(localstack.getRegion())) + .build(); + // } + + final String bucketName = "foo"; + s3.createBucket(CreateBucketRequest.builder().bucket(bucketName).build()); + s3.putObject( + PutObjectRequest.builder().bucket(bucketName).key("bar").build(), + software.amazon.awssdk.core.sync.RequestBody.fromString("baz") + ); + + final List buckets = s3.listBuckets().buckets(); + final Optional maybeBucket = buckets + .stream() + .filter(b -> b.name().equals(bucketName)) + .findFirst(); + assertThat(maybeBucket).as("The created bucket is present").isPresent(); + final Bucket bucket = maybeBucket.get(); + + assertThat(bucket.name()).as("The created bucket has the right name").isEqualTo(bucketName); + + final ListObjectsV2Response objectListing = s3.listObjectsV2( + ListObjectsV2Request.builder().bucket(bucketName).build() + ); + assertThat(objectListing.contents()).as("The created bucket has 1 item in it").hasSize(1); + + final String content = s3 + .getObjectAsBytes(GetObjectRequest.builder().bucket(bucketName).key("bar").build()) + .asString(StandardCharsets.UTF_8); + assertThat(content).as("The object can be retrieved").isEqualTo("baz"); + } + } + + @Test + void sqsTestOverBridgeNetwork() { + try ( + LocalStackContainer localstack = new LocalStackContainer(LocalstackTestImages.LOCALSTACK_IMAGE) + .withEnv("SQS_ENDPOINT_STRATEGY", "dynamic") + .withServices("sqs") + ) { + localstack.start(); + + SqsClient sqs = SqsClient + .builder() + .endpointOverride(localstack.getEndpoint()) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()) + ) + ) + .region(Region.of(localstack.getRegion())) + .build(); + + CreateQueueResponse queueResult = sqs.createQueue( + CreateQueueRequest.builder().queueName("baz").build() + ); + String fooQueueUrl = queueResult.queueUrl(); + + sqs.sendMessage(SendMessageRequest.builder().queueUrl(fooQueueUrl).messageBody("test").build()); + final long messageCount = sqs + .receiveMessage(ReceiveMessageRequest.builder().queueUrl(fooQueueUrl).build()) + .messages() + .stream() + .filter(message -> message.body().equals("test")) + .count(); + assertThat(messageCount).as("the sent message can be received").isEqualTo(1L); + } + } + + @Test + void cloudWatchLogsTestOverBridgeNetwork() { + try ( + LocalStackContainer localstack = new LocalStackContainer(LocalstackTestImages.LOCALSTACK_IMAGE) + .withServices("logs") + ) { + localstack.start(); + + CloudWatchLogsClient logs = CloudWatchLogsClient + .builder() + .endpointOverride(localstack.getEndpoint()) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()) + ) + ) + .region(Region.of(localstack.getRegion())) + .build(); + + logs.createLogGroup(CreateLogGroupRequest.builder().logGroupName("foo").build()); + + DescribeLogGroupsResponse response = logs.describeLogGroups(); + assertThat(response.logGroups()).as("One log group should be created").hasSize(1); + assertThat(response.logGroups().get(0).logGroupName()) + .as("Name of created log group is [foo]") + .isEqualTo("foo"); + } + } + + @Test + void kmsKeyCreationTest() { + try ( + LocalStackContainer localstack = new LocalStackContainer(LocalstackTestImages.LOCALSTACK_IMAGE) + .withServices("kms") + ) { + localstack.start(); + KmsClient kms = KmsClient + .builder() + .endpointOverride(localstack.getEndpoint()) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()) + ) + ) + .region(Region.of(localstack.getRegion())) + .build(); + + String desc = "AWS CMK Description"; + Tag createdByTag = Tag.builder().tagKey("CreatedBy").tagValue("StorageService").build(); + CreateKeyRequest req = CreateKeyRequest.builder().description(desc).tags(createdByTag).build(); + CreateKeyResponse key = kms.createKey(req); + + assertThat(desc) + .as("AWS KMS Customer Managed Key should be created ") + .isEqualTo(key.keyMetadata().description()); + } + } + + @Test + void samePortIsExposedForAllServices() { + try (LocalStackContainer localstack = new LocalStackContainer(LocalstackTestImages.LOCALSTACK_IMAGE)) { + localstack.start(); + + assertThat(localstack.getExposedPorts()).as("A single port is exposed").hasSize(1); + assertThat(localstack.getEndpoint().toString()) + .as("Endpoint overrides are different") + .isEqualTo(localstack.getEndpoint().toString()); + } + } + } + + @Nested + class WithNetwork { + + // with_network { + Network network = Network.newNetwork(); + + LocalStackContainer localstackInDockerNetwork = new LocalStackContainer(LocalstackTestImages.LOCALSTACK_IMAGE) + .withNetwork(network) + .withNetworkAliases("localstack") + .withServices("s3", "sqs", "logs"); + // } + + GenericContainer awsCliInDockerNetwork = new GenericContainer<>(LocalstackTestImages.AWS_CLI_IMAGE) + .withNetwork(network) + .withCreateContainerCmdModifier(cmd -> cmd.withEntrypoint("tail")) + .withCommand(" -f /dev/null") + .withEnv("AWS_ACCESS_KEY_ID", "accesskey") + .withEnv("AWS_SECRET_ACCESS_KEY", "secretkey") + .withEnv("AWS_REGION", "eu-west-1"); + + @BeforeEach + void setup() { + localstackInDockerNetwork.start(); + awsCliInDockerNetwork.start(); + } + + @AfterEach + void tearDown() { + awsCliInDockerNetwork.stop(); + localstackInDockerNetwork.stop(); + } + + @Test + void s3TestOverDockerNetwork() throws Exception { + runAwsCliAgainstDockerNetworkContainer( + "s3api create-bucket --bucket foo --create-bucket-configuration LocationConstraint=eu-west-1" + ); + runAwsCliAgainstDockerNetworkContainer("s3api list-buckets"); + runAwsCliAgainstDockerNetworkContainer("s3 ls s3://foo"); + } + + @Test + void sqsTestOverDockerNetwork() throws Exception { + final String queueCreationResponse = runAwsCliAgainstDockerNetworkContainer( + "sqs create-queue --queue-name baz" + ); + + runAwsCliAgainstDockerNetworkContainer( + String.format( + "sqs send-message --endpoint http://localstack:%d --queue-url http://sqs.eu-west-1.localhost.localstack.cloud:%d/000000000000/baz --message-body test", + LocalStackContainer.PORT, + LocalStackContainer.PORT + ) + ); + final String message = runAwsCliAgainstDockerNetworkContainer( + String.format( + "sqs receive-message --endpoint http://localstack:%d --queue-url http://sqs.eu-west-1.localhost.localstack.cloud:%d/000000000000/baz", + LocalStackContainer.PORT, + LocalStackContainer.PORT + ) + ); + + assertThat(message).as("the sent message can be received").contains("\"Body\": \"test\""); + } + + @Test + void cloudWatchLogsTestOverDockerNetwork() throws Exception { + runAwsCliAgainstDockerNetworkContainer("logs create-log-group --log-group-name foo"); + } + + private String runAwsCliAgainstDockerNetworkContainer(String command) throws Exception { + final String[] commandParts = String + .format( + "/usr/local/bin/aws --region eu-west-1 %s --endpoint-url http://localstack:%d --no-verify-ssl", + command, + LocalStackContainer.PORT + ) + .split(" "); + final Container.ExecResult execResult = awsCliInDockerNetwork.execInContainer(commandParts); + assertThat(execResult.getExitCode()).isEqualTo(0); + + final String logs = execResult.getStdout() + execResult.getStderr(); + log.info(logs); + return logs; + } + } + + @Nested + class WithRegion { + + @Test + void s3EndpointHasProperRegion() { + try ( + // with_region { + LocalStackContainer localstack = new LocalStackContainer(LocalstackTestImages.LOCALSTACK_IMAGE) + .withEnv("DEFAULT_REGION", "eu-west-1") + .withServices("s3"); + // } + ) { + localstack.start(); + assertThat(localstack.getRegion()) + .as("The endpoint configuration has right region") + .isEqualTo("eu-west-1"); + } + } + } + + @Nested + class WithoutServices { + + @Test + void s3ServiceStartLazily() { + try (LocalStackContainer localstack = new LocalStackContainer(LocalstackTestImages.LOCALSTACK_IMAGE);) { + localstack.start(); + + S3Client s3 = S3Client + .builder() + .endpointOverride(localstack.getEndpoint()) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()) + ) + ) + .region(Region.of(localstack.getRegion())) + .build(); + assertThat(s3.listBuckets().buckets()).as("S3 Service is started lazily").isEmpty(); + } + } + } + + @Nested + class S3SkipSignatureValidation { + + @Test + void shouldBeAccessibleWithCredentials() throws IOException { + try ( + LocalStackContainer localstack = new LocalStackContainer(LocalstackTestImages.LOCALSTACK_IMAGE) + .withEnv("S3_SKIP_SIGNATURE_VALIDATION", "0") + ) { + localstack.start(); + + S3Client s3 = S3Client + .builder() + .endpointOverride(localstack.getEndpoint()) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()) + ) + ) + .region(Region.of(localstack.getRegion())) + .build(); + + final String bucketName = "foo"; + + s3.createBucket(CreateBucketRequest.builder().bucket(bucketName).build()); + + s3.putObject( + PutObjectRequest.builder().bucket(bucketName).key("bar").build(), + software.amazon.awssdk.core.sync.RequestBody.fromString("baz") + ); + + final List buckets = s3.listBuckets().buckets(); + final Optional maybeBucket = buckets + .stream() + .filter(b -> b.name().equals(bucketName)) + .findFirst(); + assertThat(maybeBucket).as("The created bucket is present").isPresent(); + + S3Presigner presigner = S3Presigner + .builder() + .endpointOverride(localstack.getEndpoint()) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()) + ) + ) + .region(Region.of(localstack.getRegion())) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest + .builder() + .signatureDuration(Duration.ofMinutes(5)) + .getObjectRequest(GetObjectRequest.builder().bucket(bucketName).key("bar").build()) + .build(); + + URL presignedUrl = presigner.presignGetObject(presignRequest).url(); + + assertThat(presignedUrl).as("The presigned url is valid").isNotNull(); + final String content = IOUtils.toString(presignedUrl, StandardCharsets.UTF_8); + assertThat(content).as("The object can be retrieved").isEqualTo("baz"); + } + } + } + + @Nested + class LambdaContainerLabels { + + private byte[] createLambdaHandlerZipFile() throws IOException { + StringBuilder sb = new StringBuilder(); + sb.append("def handler(event, context):\n"); + sb.append(" return event"); + + ByteArrayOutputStream byteOutput = new ByteArrayOutputStream(); + ZipOutputStream out = new ZipOutputStream(byteOutput); + ZipEntry e = new ZipEntry("handler.py"); + out.putNextEntry(e); + + byte[] data = sb.toString().getBytes(); + out.write(data, 0, data.length); + out.closeEntry(); + out.close(); + return byteOutput.toByteArray(); + } + + @Test + void shouldLabelLambdaContainers() throws IOException { + try (LocalStackContainer localstack = new LocalStackContainer(LocalstackTestImages.LOCALSTACK_IMAGE)) { + localstack.start(); + + LambdaClient lambda = LambdaClient + .builder() + .endpointOverride(localstack.getEndpoint()) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()) + ) + ) + .region(Region.of(localstack.getRegion())) + .build(); + + // create function + byte[] handlerFile = createLambdaHandlerZipFile(); + CreateFunctionRequest createFunctionRequest = CreateFunctionRequest + .builder() + .functionName("test-function") + .runtime(Runtime.PYTHON3_11) + .handler("handler.handler") + .role("arn:aws:iam::000000000000:role/test-role") + .code(FunctionCode.builder().zipFile(SdkBytes.fromByteArray(handlerFile)).build()) + .build(); + CreateFunctionResponse createFunctionResult = lambda.createFunction(createFunctionRequest); + + try (LambdaWaiter waiter = lambda.waiter()) { + waiter.waitUntilFunctionActive( + GetFunctionConfigurationRequest + .builder() + .functionName(createFunctionResult.functionName()) + .build() + ); + } + + // invoke function once + String payload = "{\"test\": \"payload\"}"; + InvokeRequest invokeRequest = InvokeRequest + .builder() + .functionName(createFunctionResult.functionName()) + .payload(SdkBytes.fromUtf8String(payload)) + .build(); + InvokeResponse invokeResult = lambda.invoke(invokeRequest); + assertThat(invokeResult.payload().asUtf8String()) + .as("Invoke result not matching expected output") + .isEqualTo(payload); + + // assert that the spawned lambda containers has the testcontainers labels set + DockerClient dockerClient = DockerClientFactory.instance().client(); + Collection nameFilter = Collections.singleton(localstack.getContainerName().replace("_", "-")); + com.github.dockerjava.api.model.Container lambdaContainer = dockerClient + .listContainersCmd() + .withNameFilter(nameFilter) + .exec() + .stream() + .findFirst() + .orElse(null); + assertThat(lambdaContainer).as("Lambda container not found").isNotNull(); + Map labels = lambdaContainer.getLabels(); + assertThat(labels.get("org.testcontainers")).as("TestContainers label not present").isEqualTo("true"); + assertThat(labels.get("org.testcontainers.sessionId")) + .as("TestContainers session id not present") + .isNotNull(); + } + } + } +} diff --git a/modules/localstack/src/test/resources/logback-test.xml b/modules/localstack/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/localstack/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/mariadb/build.gradle b/modules/mariadb/build.gradle new file mode 100644 index 00000000000..10086b4e058 --- /dev/null +++ b/modules/mariadb/build.gradle @@ -0,0 +1,14 @@ +description = "Testcontainers :: JDBC :: MariaDB" + +dependencies { + api project(':testcontainers-jdbc') + + compileOnly project(':testcontainers-r2dbc') + compileOnly 'org.mariadb:r2dbc-mariadb:1.0.3' + + testImplementation project(':testcontainers-jdbc-test') + testImplementation 'org.mariadb.jdbc:mariadb-java-client:3.5.9' + + testImplementation testFixtures(project(':testcontainers-r2dbc')) + testRuntimeOnly 'org.mariadb:r2dbc-mariadb:1.0.3' +} diff --git a/modules/mariadb/pom.xml b/modules/mariadb/pom.xml deleted file mode 100644 index 572f5b4822a..00000000000 --- a/modules/mariadb/pom.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - 4.0.0 - - - org.testcontainers - testcontainers-parent - 1.2.1-SNAPSHOT - ../../pom.xml - - - mariadb - TestContainers :: JDBC :: MariaDB - - - - ${project.groupId} - testcontainers - ${project.version} - - - ${project.groupId} - jdbc - ${project.version} - - - - - org.mariadb.jdbc - mariadb-java-client - 1.4.6 - test - - - - - com.zaxxer - HikariCP-java6 - 2.3.8 - test - - - commons-dbutils - commons-dbutils - 1.6 - test - - - org.apache.tomcat - tomcat-jdbc - 8.5.4 - - - org.vibur - vibur-dbcp - 9.0 - - - diff --git a/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBContainer.java b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBContainer.java index 2bea9b8ad7b..6c9ffda7722 100644 --- a/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBContainer.java +++ b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBContainer.java @@ -1,43 +1,87 @@ package org.testcontainers.containers; +import com.google.common.collect.Sets; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +import java.util.Set; + /** - * Container implementation for the MariaDB project. - * - * @author Miguel Gonzalez Sanchez + * Testcontainers implementation for MariaDB. + *

+ * Supported image: {@code mariadb} + *

+ * Exposed ports: 3306 + * + * @deprecated use {@link org.testcontainers.mariadb.MariaDBContainer} instead. */ +@Deprecated public class MariaDBContainer> extends JdbcDatabaseContainer { + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("mariadb"); + + @Deprecated + public static final String DEFAULT_TAG = "10.3.6"; + public static final String NAME = "mariadb"; - public static final String IMAGE = "mariadb"; - private static final Integer MARIADB_PORT = 3306; - private static final String MARIADB_USER = "test"; - private static final String MARIADB_PASSWORD = "test"; - private static final String MARIADB_DATABASE = "test"; + + @Deprecated + public static final String IMAGE = DEFAULT_IMAGE_NAME.getUnversionedPart(); + + static final String DEFAULT_USER = "test"; + + static final String DEFAULT_PASSWORD = "test"; + + static final Integer MARIADB_PORT = 3306; + + private String databaseName = "test"; + + private String username = DEFAULT_USER; + + private String password = DEFAULT_PASSWORD; + + private static final String MARIADB_ROOT_USER = "root"; + private static final String MY_CNF_CONFIG_OVERRIDE_PARAM_NAME = "TC_MY_CNF"; - public MariaDBContainer() { - super(IMAGE + ":latest"); + public MariaDBContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); } - public MariaDBContainer(String dockerImageName) { + public MariaDBContainer(final DockerImageName dockerImageName) { super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + addExposedPort(MARIADB_PORT); } @Override - protected Integer getLivenessCheckPort() { - return getMappedPort(MARIADB_PORT); + public Set getLivenessCheckPortNumbers() { + return Sets.newHashSet(MARIADB_PORT); } @Override protected void configure() { - optionallyMapResourceParameterAsVolume(MY_CNF_CONFIG_OVERRIDE_PARAM_NAME, "/etc/mysql/conf.d", "mariadb-default-conf"); - - addExposedPort(MARIADB_PORT); - addEnv("MYSQL_DATABASE", MARIADB_DATABASE); - addEnv("MYSQL_USER", MARIADB_USER); - addEnv("MYSQL_PASSWORD", MARIADB_PASSWORD); - addEnv("MYSQL_ROOT_PASSWORD", MARIADB_PASSWORD); - setCommand("mysqld"); + optionallyMapResourceParameterAsVolume( + MY_CNF_CONFIG_OVERRIDE_PARAM_NAME, + "/etc/mysql/conf.d", + "mariadb-default-conf", + Transferable.DEFAULT_DIR_MODE + ); + + addEnv("MYSQL_DATABASE", databaseName); + + if (!MARIADB_ROOT_USER.equalsIgnoreCase(this.username)) { + addEnv("MYSQL_USER", username); + } + if (password != null && !password.isEmpty()) { + addEnv("MYSQL_PASSWORD", password); + addEnv("MYSQL_ROOT_PASSWORD", password); + } else if (MARIADB_ROOT_USER.equalsIgnoreCase(username)) { + addEnv("MYSQL_ALLOW_EMPTY_PASSWORD", "yes"); + } else { + throw new ContainerLaunchException("Empty password can be used only with the root user"); + } setStartupAttempts(3); } @@ -48,17 +92,25 @@ public String getDriverClassName() { @Override public String getJdbcUrl() { - return "jdbc:mariadb://" + getContainerIpAddress() + ":" + getMappedPort(MARIADB_PORT) + "/test"; + String additionalUrlParams = constructUrlParameters("?", "&"); + return ( + "jdbc:mariadb://" + getHost() + ":" + getMappedPort(MARIADB_PORT) + "/" + databaseName + additionalUrlParams + ); + } + + @Override + public String getDatabaseName() { + return databaseName; } @Override public String getUsername() { - return MARIADB_USER; + return username; } @Override public String getPassword() { - return MARIADB_PASSWORD; + return password; } @Override @@ -70,4 +122,22 @@ public SELF withConfigurationOverride(String s) { parameters.put(MY_CNF_CONFIG_OVERRIDE_PARAM_NAME, s); return self(); } + + @Override + public SELF withDatabaseName(final String databaseName) { + this.databaseName = databaseName; + return self(); + } + + @Override + public SELF withUsername(final String username) { + this.username = username; + return self(); + } + + @Override + public SELF withPassword(final String password) { + this.password = password; + return self(); + } } diff --git a/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBContainerProvider.java b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBContainerProvider.java index b3171b02911..6697255ca79 100644 --- a/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBContainerProvider.java +++ b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBContainerProvider.java @@ -1,16 +1,34 @@ package org.testcontainers.containers; +import org.testcontainers.jdbc.ConnectionUrl; +import org.testcontainers.utility.DockerImageName; + /** - * Factory for MariaDB containers. + * Factory for MariaDB org.testcontainers.containers. */ public class MariaDBContainerProvider extends JdbcDatabaseContainerProvider { + + private static final String USER_PARAM = "user"; + + private static final String PASSWORD_PARAM = "password"; + @Override public boolean supports(String databaseType) { return databaseType.equals(MariaDBContainer.NAME); } + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(MariaDBContainer.DEFAULT_TAG); + } + @Override public JdbcDatabaseContainer newInstance(String tag) { - return new MariaDBContainer(MariaDBContainer.IMAGE + ":" + tag); + return new MariaDBContainer(DockerImageName.parse(MariaDBContainer.IMAGE).withTag(tag)); + } + + @Override + public JdbcDatabaseContainer newInstance(ConnectionUrl connectionUrl) { + return newInstanceFromConnectionUrl(connectionUrl, USER_PARAM, PASSWORD_PARAM); } } diff --git a/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainer.java b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainer.java new file mode 100644 index 00000000000..99041e83e28 --- /dev/null +++ b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainer.java @@ -0,0 +1,35 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; + +@RequiredArgsConstructor +public class MariaDBR2DBCDatabaseContainer implements R2DBCDatabaseContainer { + + @Delegate(types = Startable.class) + private final MariaDBContainer container; + + public static ConnectionFactoryOptions getOptions(MariaDBContainer container) { + ConnectionFactoryOptions options = ConnectionFactoryOptions + .builder() + .option(ConnectionFactoryOptions.DRIVER, MariaDBR2DBCDatabaseContainerProvider.DRIVER) + .build(); + + return new MariaDBR2DBCDatabaseContainer(container).configure(options); + } + + @Override + public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { + return options + .mutate() + .option(ConnectionFactoryOptions.HOST, container.getHost()) + .option(ConnectionFactoryOptions.PORT, container.getMappedPort(MariaDBContainer.MARIADB_PORT)) + .option(ConnectionFactoryOptions.DATABASE, container.getDatabaseName()) + .option(ConnectionFactoryOptions.USER, container.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, container.getPassword()) + .build(); + } +} diff --git a/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerProvider.java b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerProvider.java new file mode 100644 index 00000000000..34635ac239f --- /dev/null +++ b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerProvider.java @@ -0,0 +1,44 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryMetadata; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.mariadb.r2dbc.MariadbConnectionFactoryProvider; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; +import org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider; + +import javax.annotation.Nullable; + +public class MariaDBR2DBCDatabaseContainerProvider implements R2DBCDatabaseContainerProvider { + + static final String DRIVER = MariadbConnectionFactoryProvider.MARIADB_DRIVER; + + @Override + public boolean supports(ConnectionFactoryOptions options) { + return DRIVER.equals(options.getRequiredValue(ConnectionFactoryOptions.DRIVER)); + } + + @Override + public R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options) { + String image = MariaDBContainer.IMAGE + ":" + options.getRequiredValue(IMAGE_TAG_OPTION); + MariaDBContainer container = new MariaDBContainer<>(image) + .withDatabaseName((String) options.getRequiredValue(ConnectionFactoryOptions.DATABASE)); + + if (Boolean.TRUE.equals(options.getValue(REUSABLE_OPTION))) { + container.withReuse(true); + } + return new MariaDBR2DBCDatabaseContainer(container); + } + + @Nullable + @Override + public ConnectionFactoryMetadata getMetadata(ConnectionFactoryOptions options) { + ConnectionFactoryOptions.Builder builder = options.mutate(); + if (!options.hasOption(ConnectionFactoryOptions.USER)) { + builder.option(ConnectionFactoryOptions.USER, MariaDBContainer.DEFAULT_USER); + } + if (!options.hasOption(ConnectionFactoryOptions.PASSWORD)) { + builder.option(ConnectionFactoryOptions.PASSWORD, MariaDBContainer.DEFAULT_PASSWORD); + } + return R2DBCDatabaseContainerProvider.super.getMetadata(builder.build()); + } +} diff --git a/modules/mariadb/src/main/java/org/testcontainers/mariadb/MariaDBContainer.java b/modules/mariadb/src/main/java/org/testcontainers/mariadb/MariaDBContainer.java new file mode 100644 index 00000000000..8487f4576f7 --- /dev/null +++ b/modules/mariadb/src/main/java/org/testcontainers/mariadb/MariaDBContainer.java @@ -0,0 +1,136 @@ +package org.testcontainers.mariadb; + +import com.google.common.collect.Sets; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +import java.util.Set; + +/** + * Testcontainers implementation for MariaDB. + *

+ * Supported image: {@code mariadb} + *

+ * Exposed ports: 3306 + */ +public class MariaDBContainer extends JdbcDatabaseContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("mariadb"); + + public static final String NAME = "mariadb"; + + static final String DEFAULT_USER = "test"; + + static final String DEFAULT_PASSWORD = "test"; + + static final Integer MARIADB_PORT = 3306; + + private String databaseName = "test"; + + private String username = DEFAULT_USER; + + private String password = DEFAULT_PASSWORD; + + private static final String MARIADB_ROOT_USER = "root"; + + private static final String MY_CNF_CONFIG_OVERRIDE_PARAM_NAME = "TC_MY_CNF"; + + public MariaDBContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public MariaDBContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + addExposedPort(MARIADB_PORT); + } + + @Override + public Set getLivenessCheckPortNumbers() { + return Sets.newHashSet(MARIADB_PORT); + } + + @Override + protected void configure() { + optionallyMapResourceParameterAsVolume( + MY_CNF_CONFIG_OVERRIDE_PARAM_NAME, + "/etc/mysql/conf.d", + null, + Transferable.DEFAULT_DIR_MODE + ); + + addEnv("MYSQL_DATABASE", databaseName); + + if (!MARIADB_ROOT_USER.equalsIgnoreCase(this.username)) { + addEnv("MYSQL_USER", username); + } + if (password != null && !password.isEmpty()) { + addEnv("MYSQL_PASSWORD", password); + addEnv("MYSQL_ROOT_PASSWORD", password); + } else if (MARIADB_ROOT_USER.equalsIgnoreCase(username)) { + addEnv("MYSQL_ALLOW_EMPTY_PASSWORD", "yes"); + } else { + throw new ContainerLaunchException("Empty password can be used only with the root user"); + } + setStartupAttempts(3); + } + + @Override + public String getDriverClassName() { + return "org.mariadb.jdbc.Driver"; + } + + @Override + public String getJdbcUrl() { + String additionalUrlParams = constructUrlParameters("?", "&"); + return ( + "jdbc:mariadb://" + getHost() + ":" + getMappedPort(MARIADB_PORT) + "/" + databaseName + additionalUrlParams + ); + } + + @Override + public String getDatabaseName() { + return databaseName; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getTestQueryString() { + return "SELECT 1"; + } + + public MariaDBContainer withConfigurationOverride(String s) { + parameters.put(MY_CNF_CONFIG_OVERRIDE_PARAM_NAME, s); + return self(); + } + + @Override + public MariaDBContainer withDatabaseName(final String databaseName) { + this.databaseName = databaseName; + return self(); + } + + @Override + public MariaDBContainer withUsername(final String username) { + this.username = username; + return self(); + } + + @Override + public MariaDBContainer withPassword(final String password) { + this.password = password; + return self(); + } +} diff --git a/modules/mariadb/src/main/java/org/testcontainers/mariadb/MariaDBR2DBCDatabaseContainer.java b/modules/mariadb/src/main/java/org/testcontainers/mariadb/MariaDBR2DBCDatabaseContainer.java new file mode 100644 index 00000000000..4c9e6350d23 --- /dev/null +++ b/modules/mariadb/src/main/java/org/testcontainers/mariadb/MariaDBR2DBCDatabaseContainer.java @@ -0,0 +1,58 @@ +package org.testcontainers.mariadb; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.mariadb.r2dbc.MariadbConnectionFactoryProvider; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; + +import java.util.Set; + +public class MariaDBR2DBCDatabaseContainer implements R2DBCDatabaseContainer { + + private final MariaDBContainer container; + + public MariaDBR2DBCDatabaseContainer(MariaDBContainer container) { + this.container = container; + } + + public static ConnectionFactoryOptions getOptions(MariaDBContainer container) { + ConnectionFactoryOptions options = ConnectionFactoryOptions + .builder() + .option(ConnectionFactoryOptions.DRIVER, MariadbConnectionFactoryProvider.MARIADB_DRIVER) + .build(); + + return new MariaDBR2DBCDatabaseContainer(container).configure(options); + } + + @Override + public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { + return options + .mutate() + .option(ConnectionFactoryOptions.HOST, container.getHost()) + .option(ConnectionFactoryOptions.PORT, container.getMappedPort(MariaDBContainer.MARIADB_PORT)) + .option(ConnectionFactoryOptions.DATABASE, container.getDatabaseName()) + .option(ConnectionFactoryOptions.USER, container.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, container.getPassword()) + .build(); + } + + @Override + public Set getDependencies() { + return this.container.getDependencies(); + } + + @Override + public void start() { + this.container.start(); + } + + @Override + public void stop() { + this.container.stop(); + } + + @Override + public void close() { + this.container.close(); + } +} diff --git a/modules/mariadb/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider b/modules/mariadb/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider new file mode 100644 index 00000000000..0c312685de9 --- /dev/null +++ b/modules/mariadb/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.containers.MariaDBR2DBCDatabaseContainerProvider diff --git a/modules/mariadb/src/main/resources/mariadb-default-conf/my.cnf b/modules/mariadb/src/main/resources/mariadb-default-conf/my.cnf index 3f6c80633e2..87d4cfecd20 100644 --- a/modules/mariadb/src/main/resources/mariadb-default-conf/my.cnf +++ b/modules/mariadb/src/main/resources/mariadb-default-conf/my.cnf @@ -9,7 +9,9 @@ sort_buffer_size = 64K read_buffer_size = 256K read_rnd_buffer_size = 256K net_buffer_length = 2K -thread_stack = 128K +thread_stack = 512K +skip-host-cache +skip-name-resolve # Don't listen on a TCP/IP port at all. This can be a security enhancement, # if all processes that need to connect to mysqld run on the same host. @@ -43,4 +45,4 @@ innodb_buffer_pool_size = 16M innodb_log_file_size = 5M innodb_log_buffer_size = 8M innodb_flush_log_at_trx_commit = 1 -innodb_lock_wait_timeout = 50 \ No newline at end of file +innodb_lock_wait_timeout = 50 diff --git a/modules/mariadb/src/test/java/org/testcontainers/MariaDBTestImages.java b/modules/mariadb/src/test/java/org/testcontainers/MariaDBTestImages.java new file mode 100644 index 00000000000..de5360cb57c --- /dev/null +++ b/modules/mariadb/src/test/java/org/testcontainers/MariaDBTestImages.java @@ -0,0 +1,7 @@ +package org.testcontainers; + +import org.testcontainers.utility.DockerImageName; + +public interface MariaDBTestImages { + DockerImageName MARIADB_IMAGE = DockerImageName.parse("mariadb:10.3.39"); +} diff --git a/modules/mariadb/src/test/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerTest.java b/modules/mariadb/src/test/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..0f4acd889d5 --- /dev/null +++ b/modules/mariadb/src/test/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerTest.java @@ -0,0 +1,23 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.r2dbc.AbstractR2DBCDatabaseContainerTest; +import org.testcontainers.utility.DockerImageName; + +public class MariaDBR2DBCDatabaseContainerTest extends AbstractR2DBCDatabaseContainerTest> { + + @Override + protected ConnectionFactoryOptions getOptions(MariaDBContainer container) { + return MariaDBR2DBCDatabaseContainer.getOptions(container); + } + + @Override + protected String createR2DBCUrl() { + return "r2dbc:tc:mariadb:///db?TC_IMAGE_TAG=10.3.39"; + } + + @Override + protected MariaDBContainer createContainer() { + return new MariaDBContainer<>(DockerImageName.parse("mariadb:10.3.39")); + } +} diff --git a/modules/mariadb/src/test/java/org/testcontainers/jdbc/mariadb/MariaDBJDBCDriverTest.java b/modules/mariadb/src/test/java/org/testcontainers/jdbc/mariadb/MariaDBJDBCDriverTest.java new file mode 100644 index 00000000000..5d3b799f0ff --- /dev/null +++ b/modules/mariadb/src/test/java/org/testcontainers/jdbc/mariadb/MariaDBJDBCDriverTest.java @@ -0,0 +1,46 @@ +package org.testcontainers.jdbc.mariadb; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +class MariaDBJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { + { "jdbc:tc:mariadb://hostname/databasename", EnumSet.noneOf(Options.class) }, + { + "jdbc:tc:mariadb://hostname/databasename?user=someuser&TC_INITSCRIPT=somepath/init_mariadb.sql", + EnumSet.of(Options.ScriptedSchema, Options.JDBCParams), + }, + { "jdbc:tc:mariadb:10.3.39://hostname/databasename", EnumSet.noneOf(Options.class) }, + { + "jdbc:tc:mariadb:10.3.39://hostname/databasename?TC_INITSCRIPT=somepath/init_unicode_mariadb.sql&useUnicode=yes&characterEncoding=utf8", + EnumSet.of(Options.CharacterSet), + }, + { + "jdbc:tc:mariadb:10.3.39://hostname/databasename?user=someuser&TC_INITSCRIPT=somepath/init_mariadb.sql", + EnumSet.of(Options.ScriptedSchema, Options.JDBCParams), + }, + { + "jdbc:tc:mariadb:10.3.39://hostname/databasename?user=someuser&TC_INITFUNCTION=org.testcontainers.jdbc.AbstractJDBCDriverTest::sampleInitFunction", + EnumSet.of(Options.ScriptedSchema, Options.JDBCParams), + }, + { + "jdbc:tc:mariadb:10.3.39://hostname/databasename?user=someuser&password=somepwd&TC_INITSCRIPT=somepath/init_mariadb.sql", + EnumSet.of(Options.ScriptedSchema, Options.JDBCParams), + }, + { + "jdbc:tc:mariadb:10.3.39://hostname/databasename?user=someuser&password=somepwd&TC_INITFUNCTION=org.testcontainers.jdbc.AbstractJDBCDriverTest::sampleInitFunction", + EnumSet.of(Options.ScriptedSchema, Options.JDBCParams), + }, + { + "jdbc:tc:mariadb:10.3.39://hostname/databasename?TC_MY_CNF=somepath/mariadb_conf_override", + EnumSet.of(Options.CustomIniFile), + }, + } + ); + } +} diff --git a/modules/mariadb/src/test/java/org/testcontainers/mariadb/MariaDBContainerTest.java b/modules/mariadb/src/test/java/org/testcontainers/mariadb/MariaDBContainerTest.java new file mode 100644 index 00000000000..99348f9168d --- /dev/null +++ b/modules/mariadb/src/test/java/org/testcontainers/mariadb/MariaDBContainerTest.java @@ -0,0 +1,156 @@ +package org.testcontainers.mariadb; + +import org.apache.commons.lang3.SystemUtils; +import org.junit.jupiter.api.Test; +import org.testcontainers.MariaDBTestImages; +import org.testcontainers.db.AbstractContainerDatabaseTest; + +import java.io.File; +import java.net.URL; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.attribute.PosixFilePermission; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +class MariaDBContainerTest extends AbstractContainerDatabaseTest { + + @Test + void testSimple() throws SQLException { + try ( // container { + MariaDBContainer mariadb = new MariaDBContainer("mariadb:10.3.39") + // } + ) { + mariadb.start(); + + ResultSet resultSet = performQuery(mariadb, "SELECT 1"); + int resultSetInt = resultSet.getInt(1); + + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + } + } + + @Test + void testSpecificVersion() throws SQLException { + try ( + MariaDBContainer mariadbOldVersion = new MariaDBContainer( + MariaDBTestImages.MARIADB_IMAGE.withTag("10.3.39") + ) + ) { + mariadbOldVersion.start(); + + ResultSet resultSet = performQuery(mariadbOldVersion, "SELECT VERSION()"); + String resultSetString = resultSet.getString(1); + + assertThat(resultSetString) + .as("The database version can be set using a container rule parameter") + .startsWith("10.3.39"); + } + } + + @Test + void testMariaDBWithCustomIniFile() throws SQLException { + assumeThat(SystemUtils.IS_OS_WINDOWS).isFalse(); + + try ( + MariaDBContainer mariadbCustomConfig = new MariaDBContainer( + MariaDBTestImages.MARIADB_IMAGE.withTag("10.3.39") + ) + .withConfigurationOverride("somepath/mariadb_conf_override") + ) { + mariadbCustomConfig.start(); + + assertThatCustomIniFileWasUsed(mariadbCustomConfig); + } + } + + @Test + void testMariaDBWithCommandOverride() throws SQLException { + try ( + MariaDBContainer mariadbCustomConfig = new MariaDBContainer(MariaDBTestImages.MARIADB_IMAGE) + .withCommand("mysqld --auto_increment_increment=10") + ) { + mariadbCustomConfig.start(); + ResultSet resultSet = performQuery(mariadbCustomConfig, "show variables like 'auto_increment_increment'"); + String result = resultSet.getString("Value"); + + assertThat(result).as("Auto increment increment should be overridden by command line").isEqualTo("10"); + } + } + + @Test + void testWithAdditionalUrlParamInJdbcUrl() { + MariaDBContainer mariaDBContainer = new MariaDBContainer(MariaDBTestImages.MARIADB_IMAGE) + .withUrlParam("connectTimeout", "40000") + .withUrlParam("rewriteBatchedStatements", "true"); + + try { + mariaDBContainer.start(); + String jdbcUrl = mariaDBContainer.getJdbcUrl(); + assertThat(jdbcUrl).contains("?"); + assertThat(jdbcUrl).contains("&"); + assertThat(jdbcUrl).contains("rewriteBatchedStatements=true"); + assertThat(jdbcUrl).contains("connectTimeout=40000"); + } finally { + mariaDBContainer.stop(); + } + } + + @Test + void testWithOnlyUserReadableCustomIniFile() throws Exception { + assumeThat(FileSystems.getDefault().supportedFileAttributeViews().contains("posix")).isTrue(); + + try ( + MariaDBContainer mariadbCustomConfig = new MariaDBContainer( + MariaDBTestImages.MARIADB_IMAGE.withTag("10.3.39") + ) + .withConfigurationOverride("somepath/mariadb_conf_override") + ) { + URL resource = this.getClass().getClassLoader().getResource("somepath/mariadb_conf_override"); + + File file = new File(resource.toURI()); + assertThat(file.isDirectory()).isTrue(); + + Set permissions = new HashSet<>( + Arrays.asList( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE + ) + ); + + Files.setPosixFilePermissions(file.toPath(), permissions); + + mariadbCustomConfig.start(); + + assertThatCustomIniFileWasUsed(mariadbCustomConfig); + } + } + + @Test + void testEmptyPasswordWithRootUser() throws SQLException { + try (MariaDBContainer mysql = new MariaDBContainer("mariadb:11.2.4").withUsername("root")) { + mysql.start(); + + ResultSet resultSet = performQuery(mysql, "SELECT 1"); + int resultSetInt = resultSet.getInt(1); + + assertThat(resultSetInt).isEqualTo(1); + } + } + + private void assertThatCustomIniFileWasUsed(MariaDBContainer mariadb) throws SQLException { + try (ResultSet resultSet = performQuery(mariadb, "SELECT @@GLOBAL.innodb_max_undo_log_size")) { + long result = resultSet.getLong(1); + assertThat(result) + .as("The InnoDB max undo log size has been set by the ini file content") + .isEqualTo(20000000); + } + } +} diff --git a/modules/mariadb/src/test/java/org/testcontainers/mariadb/MariaDBR2DBCDatabaseContainerTest.java b/modules/mariadb/src/test/java/org/testcontainers/mariadb/MariaDBR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..8260a6937df --- /dev/null +++ b/modules/mariadb/src/test/java/org/testcontainers/mariadb/MariaDBR2DBCDatabaseContainerTest.java @@ -0,0 +1,23 @@ +package org.testcontainers.mariadb; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.r2dbc.AbstractR2DBCDatabaseContainerTest; +import org.testcontainers.utility.DockerImageName; + +public class MariaDBR2DBCDatabaseContainerTest extends AbstractR2DBCDatabaseContainerTest { + + @Override + protected ConnectionFactoryOptions getOptions(MariaDBContainer container) { + return MariaDBR2DBCDatabaseContainer.getOptions(container); + } + + @Override + protected String createR2DBCUrl() { + return "r2dbc:tc:mariadb:///db?TC_IMAGE_TAG=10.3.39"; + } + + @Override + protected MariaDBContainer createContainer() { + return new MariaDBContainer(DockerImageName.parse("mariadb:10.3.39")); + } +} diff --git a/modules/mariadb/src/test/resources/logback-test.xml b/modules/mariadb/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/mariadb/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/mariadb/src/test/resources/somepath/init_mariadb.sql b/modules/mariadb/src/test/resources/somepath/init_mariadb.sql new file mode 100644 index 00000000000..2b00ee968b0 --- /dev/null +++ b/modules/mariadb/src/test/resources/somepath/init_mariadb.sql @@ -0,0 +1,5 @@ +CREATE TABLE bar ( + foo VARCHAR(255) +); + +INSERT INTO bar (foo) VALUES ('hello world'); \ No newline at end of file diff --git a/modules/mariadb/src/test/resources/somepath/init_unicode_mariadb.sql b/modules/mariadb/src/test/resources/somepath/init_unicode_mariadb.sql new file mode 100644 index 00000000000..f88969b0f30 --- /dev/null +++ b/modules/mariadb/src/test/resources/somepath/init_unicode_mariadb.sql @@ -0,0 +1,5 @@ +CREATE TABLE bar ( + foo varchar(255) character set utf8 +); + +INSERT INTO bar (foo) VALUES ('привет мир'); diff --git a/modules/jdbc-test/src/test/resources/somepath/mariadb_conf_override/my.cnf b/modules/mariadb/src/test/resources/somepath/mariadb_conf_override/my.cnf similarity index 89% rename from modules/jdbc-test/src/test/resources/somepath/mariadb_conf_override/my.cnf rename to modules/mariadb/src/test/resources/somepath/mariadb_conf_override/my.cnf index 220060976ab..c997afbfebc 100644 --- a/modules/jdbc-test/src/test/resources/somepath/mariadb_conf_override/my.cnf +++ b/modules/mariadb/src/test/resources/somepath/mariadb_conf_override/my.cnf @@ -9,14 +9,12 @@ sort_buffer_size = 64K read_buffer_size = 256K read_rnd_buffer_size = 256K net_buffer_length = 2K -thread_stack = 128K - - -innodb_file_format=Barracuda - - - +thread_stack = 512K +skip-host-cache +skip-name-resolve +# This configuration is custom to test whether config override works +innodb_max_undo_log_size = 20000000 # Don't listen on a TCP/IP port at all. This can be a security enhancement, # if all processes that need to connect to mysqld run on the same host. @@ -50,4 +48,4 @@ innodb_buffer_pool_size = 16M innodb_log_file_size = 5M innodb_log_buffer_size = 8M innodb_flush_log_at_trx_commit = 1 -innodb_lock_wait_timeout = 50 \ No newline at end of file +innodb_lock_wait_timeout = 50 diff --git a/modules/milvus/build.gradle b/modules/milvus/build.gradle new file mode 100644 index 00000000000..613971697b6 --- /dev/null +++ b/modules/milvus/build.gradle @@ -0,0 +1,7 @@ +description = "Testcontainers :: Milvus" + +dependencies { + api project(':testcontainers') + + testImplementation 'io.milvus:milvus-sdk-java:2.6.17' +} diff --git a/modules/milvus/src/main/java/org/testcontainers/milvus/MilvusContainer.java b/modules/milvus/src/main/java/org/testcontainers/milvus/MilvusContainer.java new file mode 100644 index 00000000000..57b1b483cf8 --- /dev/null +++ b/modules/milvus/src/main/java/org/testcontainers/milvus/MilvusContainer.java @@ -0,0 +1,61 @@ +package org.testcontainers.milvus; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +/** + * Testcontainers implementation for Milvus. + *

+ * Supported image: {@code milvusdb/milvus} + *

+ * Exposed ports: + *

    + *
  • Management port: 9091
  • + *
  • HTTP: 19530
  • + *
+ */ +public class MilvusContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("milvusdb/milvus"); + + private String etcdEndpoint; + + public MilvusContainer(String image) { + this(DockerImageName.parse(image)); + } + + public MilvusContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withExposedPorts(9091, 19530); + waitingFor(Wait.forHttp("/healthz").forPort(9091)); + withCommand("milvus", "run", "standalone"); + withCopyFileToContainer( + MountableFile.forClasspathResource("testcontainers/embedEtcd.yaml"), + "/milvus/configs/embedEtcd.yaml" + ); + withEnv("COMMON_STORAGETYPE", "local"); + } + + @Override + protected void configure() { + if (this.etcdEndpoint == null) { + withEnv("ETCD_USE_EMBED", "true"); + withEnv("ETCD_DATA_DIR", "/var/lib/milvus/etcd"); + withEnv("ETCD_CONFIG_PATH", "/milvus/configs/embedEtcd.yaml"); + } else { + withEnv("ETCD_ENDPOINTS", this.etcdEndpoint); + } + } + + public MilvusContainer withEtcdEndpoint(String etcdEndpoint) { + this.etcdEndpoint = etcdEndpoint; + return this; + } + + public String getEndpoint() { + return "http://" + getHost() + ":" + getMappedPort(19530); + } +} diff --git a/modules/milvus/src/main/resources/testcontainers/embedEtcd.yaml b/modules/milvus/src/main/resources/testcontainers/embedEtcd.yaml new file mode 100644 index 00000000000..2bd73bbd59f --- /dev/null +++ b/modules/milvus/src/main/resources/testcontainers/embedEtcd.yaml @@ -0,0 +1,2 @@ +listen-client-urls: http://0.0.0.0:2379 +advertise-client-urls: http://0.0.0.0:2379 diff --git a/modules/milvus/src/test/java/org/testcontainers/milvus/MilvusContainerTest.java b/modules/milvus/src/test/java/org/testcontainers/milvus/MilvusContainerTest.java new file mode 100644 index 00000000000..71ce05655f2 --- /dev/null +++ b/modules/milvus/src/test/java/org/testcontainers/milvus/MilvusContainerTest.java @@ -0,0 +1,66 @@ +package org.testcontainers.milvus; + +import io.milvus.client.MilvusServiceClient; +import io.milvus.param.ConnectParam; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.wait.strategy.Wait; + +import static org.assertj.core.api.Assertions.assertThat; + +class MilvusContainerTest { + + @Test + void withDefaultConfig() { + try ( + // milvusContainer { + MilvusContainer milvus = new MilvusContainer("milvusdb/milvus:v2.3.9") + // } + ) { + milvus.start(); + + assertThat(milvus.getEnvMap()).doesNotContainKey("ETCD_ENDPOINTS"); + assertMilvusVersion(milvus); + } + } + + @Test + void withExternalEtcd() { + try ( + // externalEtcd { + Network network = Network.newNetwork(); + GenericContainer etcd = new GenericContainer<>("quay.io/coreos/etcd:v3.5.5") + .withNetwork(network) + .withNetworkAliases("etcd") + .withCommand( + "etcd", + "-advertise-client-urls=http://127.0.0.1:2379", + "-listen-client-urls=http://0.0.0.0:2379", + "--data-dir=/etcd" + ) + .withEnv("ETCD_AUTO_COMPACTION_MODE", "revision") + .withEnv("ETCD_AUTO_COMPACTION_RETENTION", "1000") + .withEnv("ETCD_QUOTA_BACKEND_BYTES", "4294967296") + .withEnv("ETCD_SNAPSHOT_COUNT", "50000") + .waitingFor(Wait.forLogMessage(".*ready to serve client requests.*", 1)); + MilvusContainer milvus = new MilvusContainer("milvusdb/milvus:v2.3.9") + .withNetwork(network) + .withEtcdEndpoint("etcd:2379") + .dependsOn(etcd) + // } + ) { + milvus.start(); + + assertThat(milvus.getEnvMap()).doesNotContainKey("ETCD_USE_EMBED"); + assertMilvusVersion(milvus); + } + } + + private static void assertMilvusVersion(MilvusContainer milvus) { + MilvusServiceClient milvusClient = new MilvusServiceClient( + ConnectParam.newBuilder().withUri(milvus.getEndpoint()).build() + ); + assertThat(milvusClient.getVersion().getData().getVersion()).isEqualTo("v2.3.9"); + } +} diff --git a/modules/milvus/src/test/resources/logback-test.xml b/modules/milvus/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/milvus/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/minio/build.gradle b/modules/minio/build.gradle new file mode 100644 index 00000000000..ce592fced2d --- /dev/null +++ b/modules/minio/build.gradle @@ -0,0 +1,7 @@ +description = "Testcontainers :: MinIO" + +dependencies { + api project(':testcontainers') + + testImplementation("io.minio:minio:9.0.3") +} diff --git a/modules/minio/src/main/java/org/testcontainers/containers/MinIOContainer.java b/modules/minio/src/main/java/org/testcontainers/containers/MinIOContainer.java new file mode 100644 index 00000000000..dfd499194ef --- /dev/null +++ b/modules/minio/src/main/java/org/testcontainers/containers/MinIOContainer.java @@ -0,0 +1,118 @@ +package org.testcontainers.containers; + +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +/** + * Testcontainers implementation for MinIO. + *

+ * Supported image: {@code minio/minio} + *

+ * Exposed ports: + *

    + *
  • S3: 9000
  • + *
  • Console: 9001
  • + *
+ */ +public class MinIOContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("minio/minio"); + + private static final int MINIO_S3_PORT = 9000; + + private static final int MINIO_UI_PORT = 9001; + + private static final String DEFAULT_USER = "minioadmin"; + + private static final String DEFAULT_PASSWORD = "minioadmin"; + + private String userName; + + private String password; + + /** + * Constructs a MinIO container from the dockerImageName + * @param dockerImageName the full image name to use + */ + public MinIOContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + /** + * Constructs a MinIO container from the dockerImageName + * @param dockerImageName the full image name to use + */ + public MinIOContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withExposedPorts(MINIO_S3_PORT, MINIO_UI_PORT); + withCommand("server", "--console-address", ":" + MINIO_UI_PORT, "/data"); + waitingFor( + Wait + .forHttp("/minio/health/live") + .forPort(MINIO_S3_PORT) + .withStartupTimeout(Duration.of(60, ChronoUnit.SECONDS)) + ); + } + + /** + * Overrides the DEFAULT_USER + * @param userName the Root user to override + * @return this + */ + public MinIOContainer withUserName(String userName) { + this.userName = userName; + return this; + } + + /** + * Overrides the DEFAULT_PASSWORD + * @param password the Root user's password to override + * @return this + */ + public MinIOContainer withPassword(String password) { + this.password = password; + return this; + } + + /** + * Configures the MinIO container + */ + @Override + public void configure() { + if (this.userName != null) { + addEnv("MINIO_ROOT_USER", this.userName); + } else { + this.userName = DEFAULT_USER; + } + if (this.password != null) { + addEnv("MINIO_ROOT_PASSWORD", this.password); + } else { + this.password = DEFAULT_PASSWORD; + } + } + + /** + * @return the URL to upload/download objects from + */ + public String getS3URL() { + return String.format("http://%s:%s", this.getHost(), getMappedPort(MINIO_S3_PORT)); + } + + /** + * @return the Username for the Root user + */ + public String getUserName() { + return this.userName; + } + + /** + * @return the password for the Root user + */ + public String getPassword() { + return this.password; + } +} diff --git a/modules/minio/src/test/java/org/testcontainers/containers/MinIOContainerTest.java b/modules/minio/src/test/java/org/testcontainers/containers/MinIOContainerTest.java new file mode 100644 index 00000000000..0998243de8e --- /dev/null +++ b/modules/minio/src/test/java/org/testcontainers/containers/MinIOContainerTest.java @@ -0,0 +1,82 @@ +package org.testcontainers.containers; + +import io.minio.BucketExistsArgs; +import io.minio.MakeBucketArgs; +import io.minio.MinioClient; +import io.minio.StatObjectArgs; +import io.minio.StatObjectResponse; +import io.minio.UploadObjectArgs; +import org.junit.jupiter.api.Test; + +import java.net.URL; + +import static org.assertj.core.api.Assertions.assertThat; + +class MinIOContainerTest { + + @Test + void testBasicUsage() throws Exception { + try ( + // minioContainer { + MinIOContainer container = new MinIOContainer("minio/minio:RELEASE.2023-09-04T19-57-37Z"); + // } + ) { + container.start(); + + // configuringClient { + MinioClient minioClient = MinioClient + .builder() + .endpoint(container.getS3URL()) + .credentials(container.getUserName(), container.getPassword()) + .build(); + + // } + minioClient.makeBucket(MakeBucketArgs.builder().bucket("test-bucket").region("us-west-2").build()); + + BucketExistsArgs existsArgs = BucketExistsArgs.builder().bucket("test-bucket").build(); + + assertThat(minioClient.bucketExists(existsArgs)).isTrue(); + + URL file = this.getClass().getResource("/object_to_upload.txt"); + assertThat(file).isNotNull(); + minioClient.uploadObject( + UploadObjectArgs + .builder() + .bucket("test-bucket") + .object("my-objectname") + .filename(file.getPath()) + .build() + ); + + StatObjectResponse objectStat = minioClient.statObject( + StatObjectArgs.builder().bucket("test-bucket").object("my-objectname").build() + ); + + assertThat(objectStat.object()).isEqualTo("my-objectname"); + } + } + + @Test + void testDefaultUserPassword() { + try (MinIOContainer container = new MinIOContainer("minio/minio:RELEASE.2023-09-04T19-57-37Z")) { + container.start(); + assertThat(container.getUserName()).isEqualTo("minioadmin"); + assertThat(container.getPassword()).isEqualTo("minioadmin"); + } + } + + @Test + void testOverwriteUserPassword() { + try ( + // minioOverrides { + MinIOContainer container = new MinIOContainer("minio/minio:RELEASE.2023-09-04T19-57-37Z") + .withUserName("testuser") + .withPassword("testpassword"); + // } + ) { + container.start(); + assertThat(container.getUserName()).isEqualTo("testuser"); + assertThat(container.getPassword()).isEqualTo("testpassword"); + } + } +} diff --git a/modules/minio/src/test/resources/logback-test.xml b/modules/minio/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/minio/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/minio/src/test/resources/object_to_upload.txt b/modules/minio/src/test/resources/object_to_upload.txt new file mode 100644 index 00000000000..0637880d70d --- /dev/null +++ b/modules/minio/src/test/resources/object_to_upload.txt @@ -0,0 +1 @@ +This is a file diff --git a/modules/mockserver/build.gradle b/modules/mockserver/build.gradle new file mode 100644 index 00000000000..2ff2e9f34c6 --- /dev/null +++ b/modules/mockserver/build.gradle @@ -0,0 +1,8 @@ +description = "Testcontainers :: MockServer" + +dependencies { + api project(':testcontainers') + + testImplementation 'org.mock-server:mockserver-client-java:5.15.0' + testImplementation 'io.rest-assured:rest-assured:5.5.7' +} diff --git a/modules/mockserver/src/main/java/org/testcontainers/containers/MockServerContainer.java b/modules/mockserver/src/main/java/org/testcontainers/containers/MockServerContainer.java new file mode 100644 index 00000000000..47d2399d934 --- /dev/null +++ b/modules/mockserver/src/main/java/org/testcontainers/containers/MockServerContainer.java @@ -0,0 +1,60 @@ +package org.testcontainers.containers; + +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * @deprecated use {@link org.testcontainers.mockserver.MockServerContainer} instead. + */ +@Slf4j +@Deprecated +public class MockServerContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("jamesdbloom/mockserver"); + + private static final String DEFAULT_TAG = "mockserver-5.5.4"; + + @Deprecated + public static final String VERSION = DEFAULT_TAG; + + public static final int PORT = 1080; + + /** + * @deprecated use {@link #MockServerContainer(DockerImageName)} instead + */ + @Deprecated + public MockServerContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + /** + * @deprecated use {@link #MockServerContainer(DockerImageName)} instead + */ + @Deprecated + public MockServerContainer(String version) { + this(DEFAULT_IMAGE_NAME.withTag("mockserver-" + version)); + } + + public MockServerContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, DockerImageName.parse("mockserver/mockserver")); + + waitingFor(Wait.forLogMessage(".*started on port: " + PORT + ".*", 1)); + + withCommand("-serverPort " + PORT); + addExposedPorts(PORT); + } + + public String getEndpoint() { + return String.format("http://%s:%d", getHost(), getMappedPort(PORT)); + } + + public String getSecureEndpoint() { + return String.format("https://%s:%d", getHost(), getMappedPort(PORT)); + } + + public Integer getServerPort() { + return getMappedPort(PORT); + } +} diff --git a/modules/mockserver/src/main/java/org/testcontainers/mockserver/MockServerContainer.java b/modules/mockserver/src/main/java/org/testcontainers/mockserver/MockServerContainer.java new file mode 100644 index 00000000000..cbfcbc9fd66 --- /dev/null +++ b/modules/mockserver/src/main/java/org/testcontainers/mockserver/MockServerContainer.java @@ -0,0 +1,49 @@ +package org.testcontainers.mockserver; + +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +@Slf4j +public class MockServerContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("jamesdbloom/mockserver"); + + private static final String DEFAULT_TAG = "mockserver-5.5.4"; + + @Deprecated + public static final String VERSION = DEFAULT_TAG; + + public static final int PORT = 1080; + + /** + * @deprecated use {@link #MockServerContainer(DockerImageName)} instead + */ + @Deprecated + public MockServerContainer(String version) { + this(DEFAULT_IMAGE_NAME.withTag("mockserver-" + version)); + } + + public MockServerContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, DockerImageName.parse("mockserver/mockserver")); + + waitingFor(Wait.forLogMessage(".*started on port: " + PORT + ".*", 1)); + + withCommand("-serverPort " + PORT); + addExposedPorts(PORT); + } + + public String getEndpoint() { + return String.format("http://%s:%d", getHost(), getMappedPort(PORT)); + } + + public String getSecureEndpoint() { + return String.format("https://%s:%d", getHost(), getMappedPort(PORT)); + } + + public Integer getServerPort() { + return getMappedPort(PORT); + } +} diff --git a/modules/mockserver/src/test/java/org/testcontainers/mockserver/MockServerContainerTest.java b/modules/mockserver/src/test/java/org/testcontainers/mockserver/MockServerContainerTest.java new file mode 100644 index 00000000000..53325234b8b --- /dev/null +++ b/modules/mockserver/src/test/java/org/testcontainers/mockserver/MockServerContainerTest.java @@ -0,0 +1,121 @@ +package org.testcontainers.mockserver; + +import io.restassured.config.RestAssuredConfig; +import io.restassured.config.SSLConfig; +import org.apache.http.conn.ssl.SSLSocketFactory; +import org.junit.jupiter.api.Test; +import org.mockserver.client.MockServerClient; +import org.mockserver.configuration.Configuration; +import org.mockserver.logging.MockServerLogger; +import org.mockserver.socket.tls.KeyStoreFactory; +import org.testcontainers.utility.DockerImageName; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; + +class MockServerContainerTest { + + public static final DockerImageName MOCKSERVER_IMAGE = DockerImageName + .parse("mockserver/mockserver") + .withTag("mockserver-" + MockServerClient.class.getPackage().getImplementationVersion()); + + @Test + void shouldCallActualMockserverVersion() { + try ( // creatingProxy { + MockServerContainer mockServer = new MockServerContainer(MOCKSERVER_IMAGE) + // } + ) { + mockServer.start(); + + String expectedBody = "Hello World!"; + + try (MockServerClient client = new MockServerClient(mockServer.getHost(), mockServer.getServerPort())) { + assertThat(client.hasStarted()).as("Mockserver running").isTrue(); + + client.when(request().withPath("/hello")).respond(response().withBody(expectedBody)); + + assertThat(given().when().get(mockServer.getEndpoint() + "/hello").then().extract().body().asString()) + .as("MockServer returns correct result") + .isEqualTo(expectedBody); + } + } + } + + @Test + void shouldCallMockserverUsingTlsProtocol() { + try (MockServerContainer mockServer = new MockServerContainer(MOCKSERVER_IMAGE)) { + mockServer.start(); + + String expectedBody = "Hello World!"; + + try ( + MockServerClient client = new MockServerClient(mockServer.getHost(), mockServer.getServerPort()) + .withSecure(true) + ) { + assertThat(client.hasStarted()).as("Mockserver running").isTrue(); + + client.when(request().withPath("/hello")).respond(response().withBody(expectedBody)); + + assertThat(secureResponseFromMockserver(mockServer)) + .as("MockServer returns correct result") + .isEqualTo(expectedBody); + } + } + } + + @Test + void shouldCallMockserverUsingMutualTlsProtocol() { + try ( + MockServerContainer mockServer = new MockServerContainer(MOCKSERVER_IMAGE) + .withEnv("MOCKSERVER_TLS_MUTUAL_AUTHENTICATION_REQUIRED", "true") + ) { + mockServer.start(); + + String expectedBody = "Hello World!"; + + try ( + MockServerClient client = new MockServerClient(mockServer.getHost(), mockServer.getServerPort()) + .withSecure(true) + ) { + assertThat(client.hasStarted()).as("Mockserver running").isTrue(); + + client.when(request().withPath("/hello")).respond(response().withBody(expectedBody)); + + assertThat(secureResponseFromMockserver(mockServer)) + .as("MockServer returns correct result") + .isEqualTo(expectedBody); + } + } + } + + @Test + void newVersionStartsWithDefaultWaitStrategy() { + try (MockServerContainer mockServer = new MockServerContainer(MOCKSERVER_IMAGE)) { + mockServer.start(); + } + } + + private static String secureResponseFromMockserver(MockServerContainer mockServer) { + return given() + .config( + RestAssuredConfig + .config() + .sslConfig( + SSLConfig + .sslConfig() + .sslSocketFactory( + new SSLSocketFactory( + new KeyStoreFactory(Configuration.configuration(), new MockServerLogger()) + .sslContext() + ) + ) + ) + ) + .baseUri(mockServer.getSecureEndpoint()) + .get("/hello") + .body() + .asString(); + } +} diff --git a/modules/mockserver/src/test/resources/logback-test.xml b/modules/mockserver/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/mockserver/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/mongodb/build.gradle b/modules/mongodb/build.gradle new file mode 100644 index 00000000000..8e9f1420ddf --- /dev/null +++ b/modules/mongodb/build.gradle @@ -0,0 +1,7 @@ +description = "Testcontainers :: MongoDB" + +dependencies { + api project(':testcontainers') + + testImplementation("org.mongodb:mongodb-driver-sync:5.1.4") +} diff --git a/modules/mongodb/src/main/java/org/testcontainers/containers/MongoDBContainer.java b/modules/mongodb/src/main/java/org/testcontainers/containers/MongoDBContainer.java new file mode 100644 index 00000000000..d30f45739c0 --- /dev/null +++ b/modules/mongodb/src/main/java/org/testcontainers/containers/MongoDBContainer.java @@ -0,0 +1,226 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.IOException; + +/** + * Testcontainers implementation for MongoDB. + *

+ * Supported images: {@code mongo}, {@code mongodb/mongodb-community-server}, {@code mongodb/mongodb-enterprise-server} + *

+ * Exposed ports: 27017 + * + * @deprecated use {@link org.testcontainers.mongodb.MongoDBContainer} instead. + */ +@Slf4j +@Deprecated +public class MongoDBContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("mongo"); + + private static final DockerImageName COMMUNITY_SERVER_IMAGE = DockerImageName.parse( + "mongodb/mongodb-community-server" + ); + + private static final DockerImageName ENTERPRISE_SERVER_IMAGE = DockerImageName.parse( + "mongodb/mongodb-enterprise-server" + ); + + private static final String DEFAULT_TAG = "4.0.10"; + + private static final int CONTAINER_EXIT_CODE_OK = 0; + + private static final int AWAIT_INIT_REPLICA_SET_ATTEMPTS = 60; + + private static final String MONGODB_DATABASE_NAME_DEFAULT = "test"; + + private static final String STARTER_SCRIPT = "/testcontainers_start.sh"; + + private boolean shardingEnabled; + + /** + * @deprecated use {@link #MongoDBContainer(DockerImageName)} instead + */ + @Deprecated + public MongoDBContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + public MongoDBContainer(@NonNull final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public MongoDBContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, COMMUNITY_SERVER_IMAGE, ENTERPRISE_SERVER_IMAGE); + } + + @Override + MongoDBContainerDef createContainerDef() { + return new MongoDBContainerDef(); + } + + @Override + MongoDBContainerDef getContainerDef() { + return (MongoDBContainerDef) super.getContainerDef(); + } + + @Override + protected void containerIsStarting(InspectContainerResponse containerInfo) { + if (this.shardingEnabled) { + copyFileToContainer(MountableFile.forClasspathResource("/sharding.sh", 0777), STARTER_SCRIPT); + } + } + + /** + * Enables sharding on the cluster + * + * @return this + */ + public MongoDBContainer withSharding() { + this.shardingEnabled = true; + getContainerDef().withSharding(); + return this; + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo, boolean reused) { + if (!this.shardingEnabled) { + initReplicaSet(reused); + } + } + + /** + * Gets a connection string url, unlike {@link #getReplicaSetUrl} this does not point to a + * database + * @return a connection url pointing to a mongodb instance + */ + public String getConnectionString() { + return String.format("mongodb://%s:%d", getHost(), getMappedPort(MongoDBContainerDef.MONGODB_INTERNAL_PORT)); + } + + /** + * Gets a replica set url for the default {@value #MONGODB_DATABASE_NAME_DEFAULT} database. + * + * @return a replica set url. + */ + public String getReplicaSetUrl() { + return getReplicaSetUrl(MONGODB_DATABASE_NAME_DEFAULT); + } + + /** + * Gets a replica set url for a provided databaseName. + * + * @param databaseName a database name. + * @return a replica set url. + */ + public String getReplicaSetUrl(final String databaseName) { + if (!isRunning()) { + throw new IllegalStateException("MongoDBContainer should be started first"); + } + return getConnectionString() + "/" + databaseName; + } + + private String[] buildMongoEvalCommand(final String command) { + return new String[] { + "sh", + "-c", + "mongosh mongo --eval \"" + command + "\" || mongo --eval \"" + command + "\"", + }; + } + + private void checkMongoNodeExitCode(final Container.ExecResult execResult) { + if (execResult.getExitCode() != CONTAINER_EXIT_CODE_OK) { + final String errorMessage = String.format("An error occurred: %s", execResult.getStdout()); + log.error(errorMessage); + throw new ReplicaSetInitializationException(errorMessage); + } + } + + private String buildMongoWaitCommand() { + return String.format( + "var attempt = 0; " + + "while" + + "(%s) " + + "{ " + + "if (attempt > %d) {quit(1);} " + + "print('%s ' + attempt); sleep(100); attempt++; " + + " }", + "db.runCommand( { isMaster: 1 } ).ismaster==false", + AWAIT_INIT_REPLICA_SET_ATTEMPTS, + "An attempt to await for a single node replica set initialization:" + ); + } + + private void checkMongoNodeExitCodeAfterWaiting(final Container.ExecResult execResultWaitForMaster) { + if (execResultWaitForMaster.getExitCode() != CONTAINER_EXIT_CODE_OK) { + final String errorMessage = String.format( + "A single node replica set was not initialized in a set timeout: %d attempts", + AWAIT_INIT_REPLICA_SET_ATTEMPTS + ); + log.error(errorMessage); + throw new ReplicaSetInitializationException(errorMessage); + } + } + + @SneakyThrows(value = { IOException.class, InterruptedException.class }) + private void initReplicaSet(boolean reused) { + if (reused && isReplicationSetAlreadyInitialized()) { + log.debug("Replica set already initialized."); + } else { + log.debug("Initializing a single node node replica set..."); + final ExecResult execResultInitRs = execInContainer(buildMongoEvalCommand("rs.initiate();")); + log.debug(execResultInitRs.getStdout()); + checkMongoNodeExitCode(execResultInitRs); + + log.debug( + "Awaiting for a single node replica set initialization up to {} attempts", + AWAIT_INIT_REPLICA_SET_ATTEMPTS + ); + final ExecResult execResultWaitForMaster = execInContainer(buildMongoEvalCommand(buildMongoWaitCommand())); + log.debug(execResultWaitForMaster.getStdout()); + + checkMongoNodeExitCodeAfterWaiting(execResultWaitForMaster); + } + } + + public static class ReplicaSetInitializationException extends RuntimeException { + + ReplicaSetInitializationException(final String errorMessage) { + super(errorMessage); + } + } + + @SneakyThrows + private boolean isReplicationSetAlreadyInitialized() { + // since we are creating a replica set with one node, this node must be primary (state = 1) + final ExecResult execCheckRsInit = execInContainer( + buildMongoEvalCommand("if(db.adminCommand({replSetGetStatus: 1})['myState'] != 1) quit(900)") + ); + return execCheckRsInit.getExitCode() == CONTAINER_EXIT_CODE_OK; + } + + private static class MongoDBContainerDef extends ContainerDef { + + private static final int MONGODB_INTERNAL_PORT = 27017; + + MongoDBContainerDef() { + addExposedTcpPort(MONGODB_INTERNAL_PORT); + setCommand("--replSet", "docker-rs"); + setWaitStrategy(Wait.forLogMessage("(?i).*waiting for connections.*", 1)); + } + + void withSharding() { + setCommand("-c", "while [ ! -f " + STARTER_SCRIPT + " ]; do sleep 0.1; done; " + STARTER_SCRIPT); + setWaitStrategy(Wait.forLogMessage("(?i).*mongos ready.*", 1)); + setEntrypoint("sh"); + } + } +} diff --git a/modules/mongodb/src/main/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainer.java b/modules/mongodb/src/main/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainer.java new file mode 100644 index 00000000000..79e44d3ffb0 --- /dev/null +++ b/modules/mongodb/src/main/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainer.java @@ -0,0 +1,68 @@ +package org.testcontainers.mongodb; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for MongoDB Atlas. + *

+ * Supported images: {@code mongodb/mongodb-atlas-local} + *

+ * Exposed ports: 27017 + */ +public class MongoDBAtlasLocalContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("mongodb/mongodb-atlas-local"); + + private static final int MONGODB_INTERNAL_PORT = 27017; + + private static final String MONGODB_DATABASE_NAME_DEFAULT = "test"; + + private static final String DIRECT_CONNECTION = "directConnection=true"; + + public MongoDBAtlasLocalContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public MongoDBAtlasLocalContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + withExposedPorts(MONGODB_INTERNAL_PORT); + waitingFor(Wait.forSuccessfulCommand("runner healthcheck")); + } + + /** + * Get the connection string to MongoDB. + */ + public String getConnectionString() { + return baseConnectionString() + "/?" + DIRECT_CONNECTION; + } + + private String baseConnectionString() { + return String.format("mongodb://%s:%d", getHost(), getMappedPort(MONGODB_INTERNAL_PORT)); + } + + /** + * Gets a database specific connection string for the default {@value #MONGODB_DATABASE_NAME_DEFAULT} database. + * + * @return a database specific connection string. + */ + public String getDatabaseConnectionString() { + return getDatabaseConnectionString(MONGODB_DATABASE_NAME_DEFAULT); + } + + /** + * Gets a database specific connection string for a provided databaseName. + * + * @param databaseName a database name. + * @return a database specific connection string. + */ + public String getDatabaseConnectionString(final String databaseName) { + if (!isRunning()) { + throw new IllegalStateException("MongoDBContainer should be started first"); + } + return baseConnectionString() + "/" + databaseName + "?" + DIRECT_CONNECTION; + } +} diff --git a/modules/mongodb/src/main/java/org/testcontainers/mongodb/MongoDBContainer.java b/modules/mongodb/src/main/java/org/testcontainers/mongodb/MongoDBContainer.java new file mode 100644 index 00000000000..c61be6675ba --- /dev/null +++ b/modules/mongodb/src/main/java/org/testcontainers/mongodb/MongoDBContainer.java @@ -0,0 +1,207 @@ +package org.testcontainers.mongodb; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.IOException; + +/** + * Testcontainers implementation for MongoDB. + *

+ * Supported images: {@code mongo}, {@code mongodb/mongodb-community-server}, {@code mongodb/mongodb-enterprise-server} + *

+ * Exposed ports: 27017 + */ +@Slf4j +public class MongoDBContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("mongo"); + + private static final DockerImageName COMMUNITY_SERVER_IMAGE = DockerImageName.parse( + "mongodb/mongodb-community-server" + ); + + private static final DockerImageName ENTERPRISE_SERVER_IMAGE = DockerImageName.parse( + "mongodb/mongodb-enterprise-server" + ); + + private static final int MONGODB_INTERNAL_PORT = 27017; + + private static final int CONTAINER_EXIT_CODE_OK = 0; + + private static final int AWAIT_INIT_REPLICA_SET_ATTEMPTS = 60; + + private static final String MONGODB_DATABASE_NAME_DEFAULT = "test"; + + private static final String STARTER_SCRIPT = "/testcontainers_start.sh"; + + private boolean shardingEnabled; + + private boolean rsEnabled; + + public MongoDBContainer(@NonNull String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public MongoDBContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, COMMUNITY_SERVER_IMAGE, ENTERPRISE_SERVER_IMAGE); + + withExposedPorts(MONGODB_INTERNAL_PORT); + } + + @Override + protected void containerIsStarting(InspectContainerResponse containerInfo) { + if (this.shardingEnabled) { + copyFileToContainer(MountableFile.forClasspathResource("/sharding.sh", 0777), STARTER_SCRIPT); + } + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo, boolean reused) { + if (this.rsEnabled) { + initReplicaSet(reused); + } + } + + private String[] buildMongoEvalCommand(String command) { + return new String[] { + "sh", + "-c", + "mongosh mongo --eval \"" + command + "\" || mongo --eval \"" + command + "\"", + }; + } + + private void checkMongoNodeExitCode(ExecResult execResult) { + if (execResult.getExitCode() != CONTAINER_EXIT_CODE_OK) { + String errorMessage = String.format("An error occurred: %s", execResult.getStdout()); + log.error(errorMessage); + throw new ReplicaSetInitializationException(errorMessage); + } + } + + private String buildMongoWaitCommand() { + return String.format( + "var attempt = 0; " + + "while" + + "(%s) " + + "{ " + + "if (attempt > %d) {quit(1);} " + + "print('%s ' + attempt); sleep(100); attempt++; " + + " }", + "db.runCommand( { isMaster: 1 } ).ismaster==false", + AWAIT_INIT_REPLICA_SET_ATTEMPTS, + "An attempt to await for a single node replica set initialization:" + ); + } + + private void checkMongoNodeExitCodeAfterWaiting(ExecResult execResultWaitForMaster) { + if (execResultWaitForMaster.getExitCode() != CONTAINER_EXIT_CODE_OK) { + String errorMessage = String.format( + "A single node replica set was not initialized in a set timeout: %d attempts", + AWAIT_INIT_REPLICA_SET_ATTEMPTS + ); + log.error(errorMessage); + throw new ReplicaSetInitializationException(errorMessage); + } + } + + @SneakyThrows(value = { IOException.class, InterruptedException.class }) + private void initReplicaSet(boolean reused) { + if (reused && isReplicationSetAlreadyInitialized()) { + log.debug("Replica set already initialized."); + } else { + log.debug("Initializing a single node node replica set..."); + ExecResult execResultInitRs = execInContainer(buildMongoEvalCommand("rs.initiate();")); + log.debug(execResultInitRs.getStdout()); + checkMongoNodeExitCode(execResultInitRs); + + log.debug( + "Awaiting for a single node replica set initialization up to {} attempts", + AWAIT_INIT_REPLICA_SET_ATTEMPTS + ); + ExecResult execResultWaitForMaster = execInContainer(buildMongoEvalCommand(buildMongoWaitCommand())); + log.debug(execResultWaitForMaster.getStdout()); + + checkMongoNodeExitCodeAfterWaiting(execResultWaitForMaster); + } + } + + public static class ReplicaSetInitializationException extends RuntimeException { + + ReplicaSetInitializationException(String errorMessage) { + super(errorMessage); + } + } + + @SneakyThrows + private boolean isReplicationSetAlreadyInitialized() { + // since we are creating a replica set with one node, this node must be primary (state = 1) + ExecResult execCheckRsInit = execInContainer( + buildMongoEvalCommand("if(db.adminCommand({replSetGetStatus: 1})['myState'] != 1) quit(900)") + ); + return execCheckRsInit.getExitCode() == CONTAINER_EXIT_CODE_OK; + } + + /** + * Enables replica set on the cluster + * + * @return this + */ + public MongoDBContainer withReplicaSet() { + this.rsEnabled = true; + withCommand("--replSet", "docker-rs"); + waitingFor(Wait.forLogMessage("(?i).*waiting for connections.*", 1)); + return this; + } + + /** + * Enables sharding on the cluster + * + * @return this + */ + public MongoDBContainer withSharding() { + this.shardingEnabled = true; + withCommand("-c", "while [ ! -f " + STARTER_SCRIPT + " ]; do sleep 0.1; done; " + STARTER_SCRIPT); + waitingFor(Wait.forLogMessage("(?i).*mongos ready.*", 1)); + withCreateContainerCmdModifier(cmd -> cmd.withEntrypoint("sh")); + return this; + } + + /** + * Gets a connection string url, unlike {@link #getReplicaSetUrl} this does not point to a + * database + * @return a connection url pointing to a mongodb instance + */ + public String getConnectionString() { + return String.format("mongodb://%s:%d", getHost(), getMappedPort(MONGODB_INTERNAL_PORT)); + } + + /** + * Gets a replica set url for the default {@value #MONGODB_DATABASE_NAME_DEFAULT} database. + * + * @return a replica set url. + */ + public String getReplicaSetUrl() { + return getReplicaSetUrl(MONGODB_DATABASE_NAME_DEFAULT); + } + + /** + * Gets a replica set url for a provided databaseName. + * + * @param databaseName a database name. + * @return a replica set url. + */ + public String getReplicaSetUrl(String databaseName) { + if (!isRunning()) { + throw new IllegalStateException("MongoDBContainer should be started first"); + } + return getConnectionString() + "/" + databaseName; + } +} diff --git a/modules/mongodb/src/main/resources/sharding.sh b/modules/mongodb/src/main/resources/sharding.sh new file mode 100644 index 00000000000..931d62c4222 --- /dev/null +++ b/modules/mongodb/src/main/resources/sharding.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +CONFIGSVR=/tmp/mongod/configsvr +SHARDSVR=/tmp/mongod/shardsvr + +function retry() { + COUNT=${COUNT:-0} + if [ $COUNT == 5 ] + then + echo Failed $COUNT attempts + exit 1 + fi + + sleep $COUNT + echo "Attempt #$[ $COUNT + 1 ] '$*' " + eval $* + if [ $? -ne 0 ] + then + COUNT=$[ $COUNT + 1 ] + retry $* + fi + unset COUNT +} + +function initReplSet() { + PORT=$1 + COUNT=${2:-1} + + CMD="mongosh --quiet --port $PORT --eval \"if(db.adminCommand({replSetGetStatus: 1})['myState'] != 1) quit(900)\"" + eval $CMD + retVal=$? + if [ $retVal -ne 0 -a $COUNT -ne 5 ] + then + echo "Initiating replSet (attempt $COUNT)" + mongosh --quiet --port $PORT --eval 'rs.initiate();' + if [ $? -ne 0 ] + then + sleep $COUNT + initReplSet $PORT $[ $COUNT + 1 ] + fi + fi + unset COUNT +} + +rm -rf $CONFIGSVR $SHARDSVR +mkdir -p $CONFIGSVR +mkdir -p $SHARDSVR + +echo "Starting configsvr" +mongod --bind_ip_all --configsvr --port 27019 --replSet configsvr-rs --dbpath $CONFIGSVR --logpath /tmp/configsvr.log & +echo "Initiating configsvr replSet" +initReplSet 27019 + +echo "Starting shardsvr" +mongod --bind_ip_all --shardsvr --port 27018 --replSet shardsvr-rs --dbpath $SHARDSVR --logpath /tmp/shardsvr.log & + +echo "Initiating shardsvr replSet" +initReplSet 27018 + +echo "Starting mongos" +mongos --bind_ip_all --configdb configsvr-rs/localhost:27019 --logpath /tmp/mongos.log & + +echo "Adding a shard" +retry "mongosh --eval 'sh.addShard(\"shardsvr-rs/`hostname`:27018\");'" + +echo "mongos ready" +mongosh --quiet tctest --eval "db.testcollection.insertOne({});" +sleep 36000 diff --git a/modules/mongodb/src/test/java/org/testcontainers/mongodb/AbstractMongo.java b/modules/mongodb/src/test/java/org/testcontainers/mongodb/AbstractMongo.java new file mode 100644 index 00000000000..8bd0f99dbd5 --- /dev/null +++ b/modules/mongodb/src/test/java/org/testcontainers/mongodb/AbstractMongo.java @@ -0,0 +1,66 @@ +package org.testcontainers.mongodb; + +import com.mongodb.ReadConcern; +import com.mongodb.ReadPreference; +import com.mongodb.TransactionOptions; +import com.mongodb.WriteConcern; +import com.mongodb.client.ClientSession; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.TransactionBody; +import org.bson.Document; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AbstractMongo { + + protected void executeTx(MongoDBContainer mongoDBContainer) { + final MongoClient mongoSyncClientBase = MongoClients.create(mongoDBContainer.getConnectionString()); + final MongoClient mongoSyncClient = MongoClients.create(mongoDBContainer.getReplicaSetUrl()); + mongoSyncClient + .getDatabase("mydb1") + .getCollection("foo") + .withWriteConcern(WriteConcern.MAJORITY) + .insertOne(new Document("abc", 0)); + mongoSyncClient + .getDatabase("mydb2") + .getCollection("bar") + .withWriteConcern(WriteConcern.MAJORITY) + .insertOne(new Document("xyz", 0)); + mongoSyncClientBase + .getDatabase("mydb3") + .getCollection("baz") + .withWriteConcern(WriteConcern.MAJORITY) + .insertOne(new Document("def", 0)); + + final ClientSession clientSession = mongoSyncClient.startSession(); + final TransactionOptions txnOptions = TransactionOptions + .builder() + .readPreference(ReadPreference.primary()) + .readConcern(ReadConcern.LOCAL) + .writeConcern(WriteConcern.MAJORITY) + .build(); + + final String trxResult = "Inserted into collections in different databases"; + + TransactionBody txnBody = () -> { + final MongoCollection coll1 = mongoSyncClient.getDatabase("mydb1").getCollection("foo"); + final MongoCollection coll2 = mongoSyncClient.getDatabase("mydb2").getCollection("bar"); + + coll1.insertOne(clientSession, new Document("abc", 1)); + coll2.insertOne(clientSession, new Document("xyz", 999)); + return trxResult; + }; + + try { + final String trxResultActual = clientSession.withTransaction(txnBody, txnOptions); + assertThat(trxResultActual).isEqualTo(trxResult); + } catch (RuntimeException re) { + throw new IllegalStateException(re.getMessage(), re); + } finally { + clientSession.close(); + mongoSyncClient.close(); + } + } +} diff --git a/modules/mongodb/src/test/java/org/testcontainers/mongodb/AtlasLocalDataAccess.java b/modules/mongodb/src/test/java/org/testcontainers/mongodb/AtlasLocalDataAccess.java new file mode 100644 index 00000000000..c3e6aff41f9 --- /dev/null +++ b/modules/mongodb/src/test/java/org/testcontainers/mongodb/AtlasLocalDataAccess.java @@ -0,0 +1,177 @@ +package org.testcontainers.mongodb; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.ListSearchIndexesIterable; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.Aggregates; +import com.mongodb.client.model.search.SearchOperator; +import com.mongodb.client.model.search.SearchOptions; +import com.mongodb.client.model.search.SearchPath; +import org.bson.BsonDocument; +import org.bson.Document; +import org.bson.codecs.configuration.CodecRegistries; +import org.bson.codecs.configuration.CodecRegistry; +import org.bson.codecs.pojo.PojoCodecProvider; +import org.bson.conversions.Bson; +import org.bson.json.JsonWriterSettings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; + +public class AtlasLocalDataAccess implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(AtlasLocalDataAccess.class); + + private final MongoClient mongoClient; + + private final MongoDatabase testDB; + + private final MongoCollection testCollection; + + private final String collectionName; + + public AtlasLocalDataAccess(String connectionString, String databaseName, String collectionName) { + this.collectionName = collectionName; + log.info("DataAccess connecting to {}", connectionString); + + CodecRegistry pojoCodecRegistry = CodecRegistries.fromProviders( + PojoCodecProvider.builder().automatic(true).build() + ); + CodecRegistry codecRegistry = CodecRegistries.fromRegistries( + MongoClientSettings.getDefaultCodecRegistry(), + pojoCodecRegistry + ); + MongoClientSettings clientSettings = MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(connectionString)) + .codecRegistry(codecRegistry) + .build(); + mongoClient = MongoClients.create(clientSettings); + testDB = mongoClient.getDatabase(databaseName); + testCollection = testDB.getCollection(collectionName, TestData.class); + } + + @Override + public void close() { + mongoClient.close(); + } + + public void initAtlasSearchIndex() throws URISyntaxException, IOException, InterruptedException { + //Create the collection (if it doesn't exist). Required because unlike other database operations, createSearchIndex will fail if the collection doesn't exist yet + testDB.createCollection(collectionName); + + //Read the atlas search index JSON from a resource file + String atlasSearchIndexJson = new String( + Files.readAllBytes(Paths.get(getClass().getResource("/atlas-local-index.json").toURI())), + StandardCharsets.UTF_8 + ); + log.info( + "Creating Atlas Search index AtlasSearchIndex on collection {}:\n{}", + collectionName, + atlasSearchIndexJson + ); + testCollection.createSearchIndex("AtlasSearchIndex", BsonDocument.parse(atlasSearchIndexJson)); + + //wait for the atlas search index to be ready + Instant start = Instant.now(); + await() + .atMost(5, TimeUnit.SECONDS) + .pollInterval(10, TimeUnit.MILLISECONDS) + .pollInSameThread() + .until(this::getIndexStatus, "READY"::equalsIgnoreCase); + + log.info( + "Atlas Search index AtlasSearchIndex on collection {} is ready (took {} milliseconds) to create.", + collectionName, + start.until(Instant.now(), ChronoUnit.MILLIS) + ); + } + + private String getIndexStatus() { + ListSearchIndexesIterable searchIndexes = testCollection.listSearchIndexes(); + for (Document searchIndex : searchIndexes) { + if (searchIndex.get("name").equals("AtlasSearchIndex")) { + return searchIndex.getString("status"); + } + } + return null; + } + + public void insertData(TestData data) { + log.info("Inserting document {}", data); + testCollection.insertOne(data); + } + + public TestData findAtlasSearch(String test) { + Bson searchClause = Aggregates.search( + SearchOperator.of(SearchOperator.text(SearchPath.fieldPath("test"), test).fuzzy()), + SearchOptions.searchOptions().index("AtlasSearchIndex") + ); + log.trace( + "Searching for document using Atlas Search:\n{}", + searchClause.toBsonDocument().toJson(JsonWriterSettings.builder().indent(true).build()) + ); + return testCollection.aggregate(Collections.singletonList(searchClause)).first(); + } + + public static class TestData { + + String test; + + int test2; + + boolean test3; + + public TestData() {} + + public TestData(String test, int test2, boolean test3) { + this.test = test; + this.test2 = test2; + this.test3 = test3; + } + + public String getTest() { + return test; + } + + public void setTest(String test) { + this.test = test; + } + + public int getTest2() { + return test2; + } + + public void setTest2(int test2) { + this.test2 = test2; + } + + public boolean isTest3() { + return test3; + } + + public void setTest3(boolean test3) { + this.test3 = test3; + } + + @Override + public String toString() { + return "TestData{" + "test='" + test + '\'' + ", test2=" + test2 + ", test3=" + test3 + '}'; + } + } +} diff --git a/modules/mongodb/src/test/java/org/testcontainers/mongodb/CompatibleImageTest.java b/modules/mongodb/src/test/java/org/testcontainers/mongodb/CompatibleImageTest.java new file mode 100644 index 00000000000..b877dca8954 --- /dev/null +++ b/modules/mongodb/src/test/java/org/testcontainers/mongodb/CompatibleImageTest.java @@ -0,0 +1,59 @@ +package org.testcontainers.mongodb; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import org.bson.Document; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class CompatibleImageTest extends AbstractMongo { + + static String[] image() { + return new String[] { + "mongo:7", + "mongodb/mongodb-community-server:7.0.2-ubi8", + "mongodb/mongodb-enterprise-server:7.0.0-ubi8", + }; + } + + @Test + void shouldExecuteTransactions() { + try ( + // creatingMongoDBContainer { + MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:4.0.10").withReplicaSet() + // } + ) { + // startingMongoDBContainer { + mongoDBContainer.start(); + // } + executeTx(mongoDBContainer); + } + } + + @ParameterizedTest + @MethodSource("image") + void shouldSupportSharding(String image) { + try (MongoDBContainer mongoDBContainer = new MongoDBContainer(image).withSharding()) { + mongoDBContainer.start(); + final MongoClient mongoClient = MongoClients.create(mongoDBContainer.getReplicaSetUrl()); + + mongoClient.getDatabase("mydb1").getCollection("foo").insertOne(new Document("abc", 0)); + + Document shards = mongoClient.getDatabase("config").getCollection("shards").find().first(); + assertThat(shards).isNotNull(); + assertThat(shards).isNotEmpty(); + assertThat(isReplicaSet(mongoClient)).isFalse(); + } + } + + private boolean isReplicaSet(MongoClient mongoClient) { + return runIsMaster(mongoClient).get("setName") != null; + } + + private Document runIsMaster(MongoClient mongoClient) { + return mongoClient.getDatabase("admin").runCommand(new Document("ismaster", 1)); + } +} diff --git a/modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainerTest.java b/modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainerTest.java new file mode 100644 index 00000000000..7bc7a39808b --- /dev/null +++ b/modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainerTest.java @@ -0,0 +1,99 @@ +package org.testcontainers.mongodb; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class MongoDBAtlasLocalContainerTest { + + private static final Logger log = LoggerFactory.getLogger(MongoDBAtlasLocalContainerTest.class); + + @Test + void getConnectionString() { + try ( + MongoDBAtlasLocalContainer container = new MongoDBAtlasLocalContainer("mongodb/mongodb-atlas-local:7.0.9") + ) { + container.start(); + String connectionString = container.getConnectionString(); + assertThat(connectionString).isNotNull(); + assertThat(connectionString).startsWith("mongodb://"); + assertThat(connectionString) + .isEqualTo( + String.format( + "mongodb://%s:%d/?directConnection=true", + container.getHost(), + container.getFirstMappedPort() + ) + ); + } + } + + @Test + void getDatabaseConnectionString() { + try ( + MongoDBAtlasLocalContainer container = new MongoDBAtlasLocalContainer("mongodb/mongodb-atlas-local:7.0.9") + ) { + container.start(); + String databaseConnectionString = container.getDatabaseConnectionString(); + assertThat(databaseConnectionString).isNotNull(); + assertThat(databaseConnectionString).startsWith("mongodb://"); + assertThat(databaseConnectionString) + .isEqualTo( + String.format( + "mongodb://%s:%d/test?directConnection=true", + container.getHost(), + container.getFirstMappedPort() + ) + ); + } + } + + @Test + void createAtlasIndexAndSearchIt() throws Exception { + try ( + // creatingAtlasLocalContainer { + MongoDBAtlasLocalContainer atlasLocalContainer = new MongoDBAtlasLocalContainer( + "mongodb/mongodb-atlas-local:7.0.9" + ); + // } + ) { + // startingAtlasLocalContainer { + atlasLocalContainer.start(); + // } + + // getConnectionStringAtlasLocalContainer { + String connectionString = atlasLocalContainer.getConnectionString(); + // } + + try ( + AtlasLocalDataAccess atlasLocalDataAccess = new AtlasLocalDataAccess(connectionString, "test", "test") + ) { + atlasLocalDataAccess.initAtlasSearchIndex(); + + atlasLocalDataAccess.insertData(new AtlasLocalDataAccess.TestData("tests", 123, true)); + + Instant start = Instant.now(); + log.info( + "Waiting for Atlas Search to index the data by polling atlas search query (Atlas Search is eventually consistent)" + ); + await() + .atMost(5, TimeUnit.SECONDS) + .pollInterval(10, TimeUnit.MILLISECONDS) + .pollInSameThread() + .until(() -> atlasLocalDataAccess.findAtlasSearch("test"), Objects::nonNull); + log.info( + "Atlas Search indexed the new data and was searchable after {}ms.", + start.until(Instant.now(), ChronoUnit.MILLIS) + ); + } + } + } +} diff --git a/modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBContainerTest.java b/modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBContainerTest.java new file mode 100644 index 00000000000..816243d769e --- /dev/null +++ b/modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBContainerTest.java @@ -0,0 +1,41 @@ +package org.testcontainers.mongodb; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MongoDBContainerTest extends AbstractMongo { + + /** + * Taken from https://docs.mongodb.com + */ + @Test + void shouldExecuteTransactions() { + try ( + // creatingMongoDBContainer { + MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:4.0.10").withReplicaSet() + // } + ) { + // startingMongoDBContainer { + mongoDBContainer.start(); + // } + executeTx(mongoDBContainer); + } + } + + @Test + void supportsMongoDB_7_0() { + try (MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:7.0")) { + mongoDBContainer.start(); + } + } + + @Test + void shouldTestDatabaseName() { + try (MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:4.0.10")) { + mongoDBContainer.start(); + final String databaseName = "my-db"; + assertThat(mongoDBContainer.getReplicaSetUrl(databaseName)).endsWith(databaseName); + } + } +} diff --git a/modules/mongodb/src/test/resources/atlas-local-index.json b/modules/mongodb/src/test/resources/atlas-local-index.json new file mode 100644 index 00000000000..1bca016025f --- /dev/null +++ b/modules/mongodb/src/test/resources/atlas-local-index.json @@ -0,0 +1,18 @@ +{ + "mappings": { + "dynamic": false, + "fields": { + "test": { + "type": "string" + }, + "test2": { + "type": "number", + "representation": "int64", + "indexDoubles": false + }, + "test3": { + "type": "boolean" + } + } + } +} diff --git a/modules/mongodb/src/test/resources/logback-test.xml b/modules/mongodb/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/mongodb/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/mssqlserver/AUTHORS b/modules/mssqlserver/AUTHORS new file mode 100644 index 00000000000..bc3ac920da0 --- /dev/null +++ b/modules/mssqlserver/AUTHORS @@ -0,0 +1 @@ +Stefan Hufschmidt \ No newline at end of file diff --git a/modules/mssqlserver/LICENSE b/modules/mssqlserver/LICENSE new file mode 100644 index 00000000000..a3c58006a9c --- /dev/null +++ b/modules/mssqlserver/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 - 2019 G DATA Software AG and other authors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/modules/mssqlserver/build.gradle b/modules/mssqlserver/build.gradle new file mode 100644 index 00000000000..cb3e7a5b55e --- /dev/null +++ b/modules/mssqlserver/build.gradle @@ -0,0 +1,17 @@ +description = "Testcontainers :: MS SQL Server" + +dependencies { + api project(':testcontainers-jdbc') + + compileOnly project(':testcontainers-r2dbc') + compileOnly 'io.r2dbc:r2dbc-mssql:1.0.5.RELEASE' + + testImplementation project(':testcontainers-jdbc-test') + testImplementation 'com.microsoft.sqlserver:mssql-jdbc:13.4.0.jre11' + + testImplementation project(':testcontainers-r2dbc') + testRuntimeOnly 'io.r2dbc:r2dbc-mssql:1.0.5.RELEASE' + + // MSSQL's wait strategy requires the JDBC driver + testImplementation testFixtures(project(':testcontainers-r2dbc')) +} diff --git a/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainer.java b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainer.java new file mode 100644 index 00000000000..6a58b3488ff --- /dev/null +++ b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainer.java @@ -0,0 +1,36 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; + +@RequiredArgsConstructor +public class MSSQLR2DBCDatabaseContainer implements R2DBCDatabaseContainer { + + @Delegate(types = Startable.class) + private final MSSQLServerContainer container; + + public static ConnectionFactoryOptions getOptions(MSSQLServerContainer container) { + ConnectionFactoryOptions options = ConnectionFactoryOptions + .builder() + .option(ConnectionFactoryOptions.DRIVER, MSSQLR2DBCDatabaseContainerProvider.DRIVER) + .build(); + + return new MSSQLR2DBCDatabaseContainer(container).configure(options); + } + + @Override + public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { + return options + .mutate() + .option(ConnectionFactoryOptions.HOST, container.getHost()) + .option(ConnectionFactoryOptions.PORT, container.getMappedPort(MSSQLServerContainer.MS_SQL_SERVER_PORT)) + // TODO enable if/when MSSQLServerContainer adds support for customizing the DB name + // .option(ConnectionFactoryOptions.DATABASE, container.getDatabasseName()) + .option(ConnectionFactoryOptions.USER, container.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, container.getPassword()) + .build(); + } +} diff --git a/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerProvider.java b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerProvider.java new file mode 100644 index 00000000000..f829ddb8f1f --- /dev/null +++ b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerProvider.java @@ -0,0 +1,44 @@ +package org.testcontainers.containers; + +import io.r2dbc.mssql.MssqlConnectionFactoryProvider; +import io.r2dbc.spi.ConnectionFactoryMetadata; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; +import org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider; + +import javax.annotation.Nullable; + +public class MSSQLR2DBCDatabaseContainerProvider implements R2DBCDatabaseContainerProvider { + + static final String DRIVER = MssqlConnectionFactoryProvider.MSSQL_DRIVER; + + @Override + public boolean supports(ConnectionFactoryOptions options) { + return DRIVER.equals(options.getRequiredValue(ConnectionFactoryOptions.DRIVER)); + } + + @Override + public R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options) { + // TODO work out how best to do this if these constants become private + String image = MSSQLServerContainer.IMAGE + ":" + options.getRequiredValue(IMAGE_TAG_OPTION); + MSSQLServerContainer container = new MSSQLServerContainer<>(image); + + if (Boolean.TRUE.equals(options.getValue(REUSABLE_OPTION))) { + container.withReuse(true); + } + return new MSSQLR2DBCDatabaseContainer(container); + } + + @Nullable + @Override + public ConnectionFactoryMetadata getMetadata(ConnectionFactoryOptions options) { + ConnectionFactoryOptions.Builder builder = options.mutate(); + if (!options.hasOption(ConnectionFactoryOptions.USER)) { + builder.option(ConnectionFactoryOptions.USER, MSSQLServerContainer.DEFAULT_USER); + } + if (!options.hasOption(ConnectionFactoryOptions.PASSWORD)) { + builder.option(ConnectionFactoryOptions.PASSWORD, MSSQLServerContainer.DEFAULT_PASSWORD); + } + return R2DBCDatabaseContainerProvider.super.getMetadata(builder.build()); + } +} diff --git a/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLServerContainer.java b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLServerContainer.java new file mode 100644 index 00000000000..07f8a064c00 --- /dev/null +++ b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLServerContainer.java @@ -0,0 +1,169 @@ +package org.testcontainers.containers; + +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.LicenseAcceptance; + +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Testcontainers implementation for Microsoft SQL Server. + *

+ * Supported image: {@code mcr.microsoft.com/mssql/server} + *

+ * Exposed ports: 1433 + * + * @deprecated use {@link org.testcontainers.mssqlserver.MSSQLServerContainer} instead. + */ +@Deprecated +public class MSSQLServerContainer> extends JdbcDatabaseContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("mcr.microsoft.com/mssql/server"); + + @Deprecated + public static final String DEFAULT_TAG = "2017-CU12"; + + public static final String NAME = "sqlserver"; + + public static final String IMAGE = DEFAULT_IMAGE_NAME.getUnversionedPart(); + + public static final Integer MS_SQL_SERVER_PORT = 1433; + + static final String DEFAULT_USER = "sa"; + + static final String DEFAULT_PASSWORD = "A_Str0ng_Required_Password"; + + private String password = DEFAULT_PASSWORD; + + private static final int DEFAULT_STARTUP_TIMEOUT_SECONDS = 240; + + private static final int DEFAULT_CONNECT_TIMEOUT_SECONDS = 240; + + private static final Pattern[] PASSWORD_CATEGORY_VALIDATION_PATTERNS = new Pattern[] { + Pattern.compile("[A-Z]+"), + Pattern.compile("[a-z]+"), + Pattern.compile("[0-9]+"), + Pattern.compile("[^a-zA-Z0-9]+", Pattern.CASE_INSENSITIVE), + }; + + /** + * @deprecated use {@link #MSSQLServerContainer(DockerImageName)} instead + */ + @Deprecated + public MSSQLServerContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + public MSSQLServerContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public MSSQLServerContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + withStartupTimeoutSeconds(DEFAULT_STARTUP_TIMEOUT_SECONDS); + withConnectTimeoutSeconds(DEFAULT_CONNECT_TIMEOUT_SECONDS); + addExposedPort(MS_SQL_SERVER_PORT); + } + + @Override + public Set getLivenessCheckPortNumbers() { + return super.getLivenessCheckPortNumbers(); + } + + @Override + protected void configure() { + // If license was not accepted programmatically, check if it was accepted via resource file + if (!getEnvMap().containsKey("ACCEPT_EULA")) { + LicenseAcceptance.assertLicenseAccepted(this.getDockerImageName()); + acceptLicense(); + } + + addEnv("MSSQL_SA_PASSWORD", password); + } + + /** + * Accepts the license for the SQLServer container by setting the ACCEPT_EULA=Y + * variable as described at https://hub.docker.com/_/microsoft-mssql-server + */ + public SELF acceptLicense() { + addEnv("ACCEPT_EULA", "Y"); + return self(); + } + + @Override + public String getDriverClassName() { + return "com.microsoft.sqlserver.jdbc.SQLServerDriver"; + } + + @Override + protected String constructUrlForConnection(String queryString) { + // The JDBC driver of MS SQL Server enables encryption by default for versions > 10.1.0. + // We need to disable it by default to be able to use the container without having to pass extra params. + // See https://github.com/microsoft/mssql-jdbc/releases/tag/v10.1.0 + if (urlParameters.keySet().stream().map(String::toLowerCase).noneMatch("encrypt"::equals)) { + urlParameters.put("encrypt", "false"); + } + return super.constructUrlForConnection(queryString); + } + + @Override + public String getJdbcUrl() { + String additionalUrlParams = constructUrlParameters(";", ";"); + return "jdbc:sqlserver://" + getHost() + ":" + getMappedPort(MS_SQL_SERVER_PORT) + additionalUrlParams; + } + + @Override + public String getUsername() { + return DEFAULT_USER; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getTestQueryString() { + return "SELECT 1"; + } + + @Override + public SELF withPassword(final String password) { + checkPasswordStrength(password); + this.password = password; + return self(); + } + + private void checkPasswordStrength(String password) { + if (password == null) { + throw new IllegalArgumentException("Null password is not allowed"); + } + + if (password.length() < 8) { + throw new IllegalArgumentException("Password should be at least 8 characters long"); + } + + if (password.length() > 128) { + throw new IllegalArgumentException("Password can be up to 128 characters long"); + } + + long satisfiedCategories = Stream + .of(PASSWORD_CATEGORY_VALIDATION_PATTERNS) + .filter(p -> p.matcher(password).find()) + .count(); + + if (satisfiedCategories < 3) { + throw new IllegalArgumentException( + "Password must contain characters from three of the following four categories:\n" + + " - Latin uppercase letters (A through Z)\n" + + " - Latin lowercase letters (a through z)\n" + + " - Base 10 digits (0 through 9)\n" + + " - Non-alphanumeric characters such as: exclamation point (!), dollar sign ($), number sign (#), " + + "or percent (%)." + ); + } + } +} diff --git a/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLServerContainerProvider.java b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLServerContainerProvider.java new file mode 100644 index 00000000000..27e8ab75225 --- /dev/null +++ b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLServerContainerProvider.java @@ -0,0 +1,24 @@ +package org.testcontainers.containers; + +import org.testcontainers.utility.DockerImageName; + +/** + * Factory for MS SQL Server containers. + */ +public class MSSQLServerContainerProvider extends JdbcDatabaseContainerProvider { + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(MSSQLServerContainer.NAME); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(MSSQLServerContainer.DEFAULT_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + return new MSSQLServerContainer(DockerImageName.parse(MSSQLServerContainer.IMAGE).withTag(tag)); + } +} diff --git a/modules/mssqlserver/src/main/java/org/testcontainers/mssqlserver/MSSQLR2DBCDatabaseContainer.java b/modules/mssqlserver/src/main/java/org/testcontainers/mssqlserver/MSSQLR2DBCDatabaseContainer.java new file mode 100644 index 00000000000..d8d7740e01d --- /dev/null +++ b/modules/mssqlserver/src/main/java/org/testcontainers/mssqlserver/MSSQLR2DBCDatabaseContainer.java @@ -0,0 +1,59 @@ +package org.testcontainers.mssqlserver; + +import io.r2dbc.mssql.MssqlConnectionFactoryProvider; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; + +import java.util.Set; + +public class MSSQLR2DBCDatabaseContainer implements R2DBCDatabaseContainer { + + private final MSSQLServerContainer container; + + public MSSQLR2DBCDatabaseContainer(MSSQLServerContainer container) { + this.container = container; + } + + public static ConnectionFactoryOptions getOptions(MSSQLServerContainer container) { + ConnectionFactoryOptions options = ConnectionFactoryOptions + .builder() + .option(ConnectionFactoryOptions.DRIVER, MssqlConnectionFactoryProvider.MSSQL_DRIVER) + .build(); + + return new MSSQLR2DBCDatabaseContainer(container).configure(options); + } + + @Override + public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { + return options + .mutate() + .option(ConnectionFactoryOptions.HOST, container.getHost()) + .option(ConnectionFactoryOptions.PORT, container.getMappedPort(MSSQLServerContainer.MS_SQL_SERVER_PORT)) + // TODO enable if/when MSSQLServerContainer adds support for customizing the DB name + // .option(ConnectionFactoryOptions.DATABASE, container.getDatabasseName()) + .option(ConnectionFactoryOptions.USER, container.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, container.getPassword()) + .build(); + } + + @Override + public Set getDependencies() { + return this.container.getDependencies(); + } + + @Override + public void start() { + this.container.start(); + } + + @Override + public void stop() { + this.container.stop(); + } + + @Override + public void close() { + this.container.close(); + } +} diff --git a/modules/mssqlserver/src/main/java/org/testcontainers/mssqlserver/MSSQLServerContainer.java b/modules/mssqlserver/src/main/java/org/testcontainers/mssqlserver/MSSQLServerContainer.java new file mode 100644 index 00000000000..6ad74ea7e72 --- /dev/null +++ b/modules/mssqlserver/src/main/java/org/testcontainers/mssqlserver/MSSQLServerContainer.java @@ -0,0 +1,156 @@ +package org.testcontainers.mssqlserver; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.LicenseAcceptance; + +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Testcontainers implementation for Microsoft SQL Server. + *

+ * Supported image: {@code mcr.microsoft.com/mssql/server} + *

+ * Exposed ports: 1433 + */ +public class MSSQLServerContainer extends JdbcDatabaseContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("mcr.microsoft.com/mssql/server"); + + public static final String NAME = "sqlserver"; + + public static final String IMAGE = DEFAULT_IMAGE_NAME.getUnversionedPart(); + + public static final Integer MS_SQL_SERVER_PORT = 1433; + + static final String DEFAULT_USER = "sa"; + + static final String DEFAULT_PASSWORD = "A_Str0ng_Required_Password"; + + private String password = DEFAULT_PASSWORD; + + private static final int DEFAULT_STARTUP_TIMEOUT_SECONDS = 240; + + private static final int DEFAULT_CONNECT_TIMEOUT_SECONDS = 240; + + private static final Pattern[] PASSWORD_CATEGORY_VALIDATION_PATTERNS = new Pattern[] { + Pattern.compile("[A-Z]+"), + Pattern.compile("[a-z]+"), + Pattern.compile("[0-9]+"), + Pattern.compile("[^a-zA-Z0-9]+", Pattern.CASE_INSENSITIVE), + }; + + public MSSQLServerContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public MSSQLServerContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + withStartupTimeoutSeconds(DEFAULT_STARTUP_TIMEOUT_SECONDS); + withConnectTimeoutSeconds(DEFAULT_CONNECT_TIMEOUT_SECONDS); + addExposedPort(MS_SQL_SERVER_PORT); + } + + @Override + public Set getLivenessCheckPortNumbers() { + return super.getLivenessCheckPortNumbers(); + } + + @Override + protected void configure() { + // If license was not accepted programmatically, check if it was accepted via resource file + if (!getEnvMap().containsKey("ACCEPT_EULA")) { + LicenseAcceptance.assertLicenseAccepted(this.getDockerImageName()); + acceptLicense(); + } + + addEnv("MSSQL_SA_PASSWORD", password); + } + + /** + * Accepts the license for the SQLServer container by setting the ACCEPT_EULA=Y + * variable as described at https://hub.docker.com/_/microsoft-mssql-server + */ + public MSSQLServerContainer acceptLicense() { + addEnv("ACCEPT_EULA", "Y"); + return self(); + } + + @Override + public String getDriverClassName() { + return "com.microsoft.sqlserver.jdbc.SQLServerDriver"; + } + + @Override + protected String constructUrlForConnection(String queryString) { + // The JDBC driver of MS SQL Server enables encryption by default for versions > 10.1.0. + // We need to disable it by default to be able to use the container without having to pass extra params. + // See https://github.com/microsoft/mssql-jdbc/releases/tag/v10.1.0 + if (urlParameters.keySet().stream().map(String::toLowerCase).noneMatch("encrypt"::equals)) { + urlParameters.put("encrypt", "false"); + } + return super.constructUrlForConnection(queryString); + } + + @Override + public String getJdbcUrl() { + String additionalUrlParams = constructUrlParameters(";", ";"); + return "jdbc:sqlserver://" + getHost() + ":" + getMappedPort(MS_SQL_SERVER_PORT) + additionalUrlParams; + } + + @Override + public String getUsername() { + return DEFAULT_USER; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getTestQueryString() { + return "SELECT 1"; + } + + @Override + public MSSQLServerContainer withPassword(final String password) { + checkPasswordStrength(password); + this.password = password; + return self(); + } + + private void checkPasswordStrength(String password) { + if (password == null) { + throw new IllegalArgumentException("Null password is not allowed"); + } + + if (password.length() < 8) { + throw new IllegalArgumentException("Password should be at least 8 characters long"); + } + + if (password.length() > 128) { + throw new IllegalArgumentException("Password can be up to 128 characters long"); + } + + long satisfiedCategories = Stream + .of(PASSWORD_CATEGORY_VALIDATION_PATTERNS) + .filter(p -> p.matcher(password).find()) + .count(); + + if (satisfiedCategories < 3) { + throw new IllegalArgumentException( + "Password must contain characters from three of the following four categories:\n" + + " - Latin uppercase letters (A through Z)\n" + + " - Latin lowercase letters (a through z)\n" + + " - Base 10 digits (0 through 9)\n" + + " - Non-alphanumeric characters such as: exclamation point (!), dollar sign ($), number sign (#), " + + "or percent (%)." + ); + } + } +} diff --git a/modules/mssqlserver/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/mssqlserver/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 00000000000..09b0a068a7f --- /dev/null +++ b/modules/mssqlserver/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.containers.MSSQLServerContainerProvider \ No newline at end of file diff --git a/modules/mssqlserver/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider b/modules/mssqlserver/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider new file mode 100644 index 00000000000..0ec6b22ddf6 --- /dev/null +++ b/modules/mssqlserver/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.containers.MSSQLR2DBCDatabaseContainerProvider diff --git a/modules/mssqlserver/src/test/java/org/testcontainers/MSSQLServerTestImages.java b/modules/mssqlserver/src/test/java/org/testcontainers/MSSQLServerTestImages.java new file mode 100644 index 00000000000..804691e2baf --- /dev/null +++ b/modules/mssqlserver/src/test/java/org/testcontainers/MSSQLServerTestImages.java @@ -0,0 +1,7 @@ +package org.testcontainers; + +import org.testcontainers.utility.DockerImageName; + +public interface MSSQLServerTestImages { + DockerImageName MSSQL_SERVER_IMAGE = DockerImageName.parse("mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04"); +} diff --git a/modules/mssqlserver/src/test/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerTest.java b/modules/mssqlserver/src/test/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..a0d3106f9c6 --- /dev/null +++ b/modules/mssqlserver/src/test/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerTest.java @@ -0,0 +1,23 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.MSSQLServerTestImages; +import org.testcontainers.r2dbc.AbstractR2DBCDatabaseContainerTest; + +public class MSSQLR2DBCDatabaseContainerTest extends AbstractR2DBCDatabaseContainerTest> { + + @Override + protected ConnectionFactoryOptions getOptions(MSSQLServerContainer container) { + return MSSQLR2DBCDatabaseContainer.getOptions(container); + } + + @Override + protected String createR2DBCUrl() { + return "r2dbc:tc:sqlserver:///?TC_IMAGE_TAG=2022-CU14-ubuntu-22.04"; + } + + @Override + protected MSSQLServerContainer createContainer() { + return new MSSQLServerContainer<>(MSSQLServerTestImages.MSSQL_SERVER_IMAGE); + } +} diff --git a/modules/mssqlserver/src/test/java/org/testcontainers/jdbc/mssqlserver/MSSQLServerJDBCDriverTest.java b/modules/mssqlserver/src/test/java/org/testcontainers/jdbc/mssqlserver/MSSQLServerJDBCDriverTest.java new file mode 100644 index 00000000000..9f616ab10f8 --- /dev/null +++ b/modules/mssqlserver/src/test/java/org/testcontainers/jdbc/mssqlserver/MSSQLServerJDBCDriverTest.java @@ -0,0 +1,20 @@ +package org.testcontainers.jdbc.mssqlserver; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +class MSSQLServerJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { + { + "jdbc:tc:sqlserver:2022-CU14-ubuntu-22.04://hostname:hostport;databaseName=databasename", + EnumSet.noneOf(Options.class), + }, + } + ); + } +} diff --git a/modules/mssqlserver/src/test/java/org/testcontainers/mssqlserver/CustomPasswordMSSQLServerTest.java b/modules/mssqlserver/src/test/java/org/testcontainers/mssqlserver/CustomPasswordMSSQLServerTest.java new file mode 100644 index 00000000000..faaaabd7d01 --- /dev/null +++ b/modules/mssqlserver/src/test/java/org/testcontainers/mssqlserver/CustomPasswordMSSQLServerTest.java @@ -0,0 +1,65 @@ +package org.testcontainers.mssqlserver; + +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.MSSQLServerTestImages; +import org.testcontainers.containers.MSSQLServerContainer; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.fail; + +/** + * Tests if the password passed to the container satisfied the password policy described at + * https://docs.microsoft.com/en-us/sql/relational-databases/security/password-policy?view=sql-server-2017 + */ +public class CustomPasswordMSSQLServerTest { + + private static String UPPER_CASE_LETTERS = "ABCDE"; + + private static String LOWER_CASE_LETTERS = "abcde"; + + private static String NUMBERS = "12345"; + + private static String SPECIAL_CHARS = "_(!)_"; + + public static Stream data() { + return Stream.of( + Arguments.arguments(null, false), + // too short + Arguments.arguments("abc123", false), + // too long + Arguments.arguments(RandomStringUtils.randomAlphabetic(129), false), + // only 2 categories + Arguments.arguments(UPPER_CASE_LETTERS + NUMBERS, false), + Arguments.arguments(UPPER_CASE_LETTERS + SPECIAL_CHARS, false), + Arguments.arguments(LOWER_CASE_LETTERS + NUMBERS, false), + Arguments.arguments(LOWER_CASE_LETTERS + SPECIAL_CHARS, false), + Arguments.arguments(NUMBERS + SPECIAL_CHARS, false), + // 3 categories + Arguments.arguments(UPPER_CASE_LETTERS + LOWER_CASE_LETTERS + NUMBERS, true), + Arguments.arguments(UPPER_CASE_LETTERS + LOWER_CASE_LETTERS + SPECIAL_CHARS, true), + Arguments.arguments(UPPER_CASE_LETTERS + NUMBERS + SPECIAL_CHARS, true), + Arguments.arguments(LOWER_CASE_LETTERS + NUMBERS + SPECIAL_CHARS, true), + // 4 categories + Arguments.arguments(UPPER_CASE_LETTERS + LOWER_CASE_LETTERS + NUMBERS + SPECIAL_CHARS, true) + ); + } + + @ParameterizedTest + @MethodSource("data") + public void runPasswordTests(String password, boolean valid) { + try { + new MSSQLServerContainer<>(MSSQLServerTestImages.MSSQL_SERVER_IMAGE).withPassword(password); + if (!valid) { + fail("Password " + password + " is not valid. Expected exception"); + } + } catch (IllegalArgumentException e) { + if (valid) { + fail("Password " + password + " should have been validated"); + } + } + } +} diff --git a/modules/mssqlserver/src/test/java/org/testcontainers/mssqlserver/MSSQLR2DBCDatabaseContainerTest.java b/modules/mssqlserver/src/test/java/org/testcontainers/mssqlserver/MSSQLR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..7b51e8f1f93 --- /dev/null +++ b/modules/mssqlserver/src/test/java/org/testcontainers/mssqlserver/MSSQLR2DBCDatabaseContainerTest.java @@ -0,0 +1,23 @@ +package org.testcontainers.mssqlserver; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.MSSQLServerTestImages; +import org.testcontainers.r2dbc.AbstractR2DBCDatabaseContainerTest; + +class MSSQLR2DBCDatabaseContainerTest extends AbstractR2DBCDatabaseContainerTest { + + @Override + protected ConnectionFactoryOptions getOptions(MSSQLServerContainer container) { + return MSSQLR2DBCDatabaseContainer.getOptions(container); + } + + @Override + protected String createR2DBCUrl() { + return "r2dbc:tc:sqlserver:///?TC_IMAGE_TAG=2022-CU14-ubuntu-22.04"; + } + + @Override + protected MSSQLServerContainer createContainer() { + return new MSSQLServerContainer(MSSQLServerTestImages.MSSQL_SERVER_IMAGE); + } +} diff --git a/modules/mssqlserver/src/test/java/org/testcontainers/mssqlserver/MSSQLServerContainerTest.java b/modules/mssqlserver/src/test/java/org/testcontainers/mssqlserver/MSSQLServerContainerTest.java new file mode 100644 index 00000000000..52269f11b9e --- /dev/null +++ b/modules/mssqlserver/src/test/java/org/testcontainers/mssqlserver/MSSQLServerContainerTest.java @@ -0,0 +1,92 @@ +package org.testcontainers.mssqlserver; + +import org.junit.jupiter.api.Test; +import org.testcontainers.MSSQLServerTestImages; +import org.testcontainers.db.AbstractContainerDatabaseTest; +import org.testcontainers.utility.DockerImageName; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class MSSQLServerContainerTest extends AbstractContainerDatabaseTest { + + @Test + void testSimple() throws SQLException { + try ( // container { + MSSQLServerContainer mssqlServer = new MSSQLServerContainer( + "mcr.microsoft.com/mssql/server:2022-CU20-ubuntu-22.04" + ) + .acceptLicense() + // } + ) { + mssqlServer.start(); + ResultSet resultSet = performQuery(mssqlServer, "SELECT 1"); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + assertHasCorrectExposedAndLivenessCheckPorts(mssqlServer); + } + } + + @Test + void testWithAdditionalUrlParamInJdbcUrl() { + try ( + MSSQLServerContainer mssqlServer = new MSSQLServerContainer(MSSQLServerTestImages.MSSQL_SERVER_IMAGE) + .withUrlParam("integratedSecurity", "false") + .withUrlParam("applicationName", "MyApp") + ) { + mssqlServer.start(); + + String jdbcUrl = mssqlServer.getJdbcUrl(); + assertThat(jdbcUrl).contains(";integratedSecurity=false;applicationName=MyApp"); + } + } + + @Test + void testSetupDatabase() throws SQLException { + try (MSSQLServerContainer mssqlServer = new MSSQLServerContainer(MSSQLServerTestImages.MSSQL_SERVER_IMAGE)) { + mssqlServer.start(); + DataSource ds = getDataSource(mssqlServer); + Statement statement = ds.getConnection().createStatement(); + statement.executeUpdate("CREATE DATABASE [test];"); + statement = ds.getConnection().createStatement(); + statement.executeUpdate("CREATE TABLE [test].[dbo].[Foo](ID INT PRIMARY KEY);"); + statement = ds.getConnection().createStatement(); + statement.executeUpdate("INSERT INTO [test].[dbo].[Foo] (ID) VALUES (3);"); + statement = ds.getConnection().createStatement(); + statement.execute("SELECT * FROM [test].[dbo].[Foo];"); + ResultSet resultSet = statement.getResultSet(); + + resultSet.next(); + int resultSetInt = resultSet.getInt("ID"); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(3); + } + } + + @Test + void testSqlServerConnection() throws SQLException { + try ( + MSSQLServerContainer mssqlServerContainer = new MSSQLServerContainer( + DockerImageName.parse("mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04") + ) + .withPassword("myStrong(!)Password") + ) { + mssqlServerContainer.start(); + + ResultSet resultSet = performQuery(mssqlServerContainer, mssqlServerContainer.getTestQueryString()); + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + } + } + + private void assertHasCorrectExposedAndLivenessCheckPorts(MSSQLServerContainer mssqlServer) { + assertThat(mssqlServer.getExposedPorts()).containsExactly(MSSQLServerContainer.MS_SQL_SERVER_PORT); + assertThat(mssqlServer.getLivenessCheckPortNumbers()) + .containsExactly(mssqlServer.getMappedPort(MSSQLServerContainer.MS_SQL_SERVER_PORT)); + } +} diff --git a/modules/mssqlserver/src/test/resources/container-license-acceptance.txt b/modules/mssqlserver/src/test/resources/container-license-acceptance.txt new file mode 100644 index 00000000000..5ae6ecbd4d1 --- /dev/null +++ b/modules/mssqlserver/src/test/resources/container-license-acceptance.txt @@ -0,0 +1 @@ +mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04 diff --git a/modules/mssqlserver/src/test/resources/logback-test.xml b/modules/mssqlserver/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/mssqlserver/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/mysql/build.gradle b/modules/mysql/build.gradle new file mode 100644 index 00000000000..2cf8b17d64f --- /dev/null +++ b/modules/mysql/build.gradle @@ -0,0 +1,16 @@ +description = "Testcontainers :: JDBC :: MySQL" + +dependencies { + api project(':testcontainers-jdbc') + + compileOnly project(':testcontainers-r2dbc') + compileOnly 'io.asyncer:r2dbc-mysql:1.4.1' + + testImplementation project(':testcontainers-jdbc-test') + testRuntimeOnly 'com.mysql:mysql-connector-j:9.6.0' + + testImplementation testFixtures(project(':testcontainers-r2dbc')) + testRuntimeOnly 'io.asyncer:r2dbc-mysql:1.4.1' + + compileOnly 'org.jetbrains:annotations:26.1.0' +} diff --git a/modules/mysql/pom.xml b/modules/mysql/pom.xml deleted file mode 100644 index c3837173126..00000000000 --- a/modules/mysql/pom.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - 4.0.0 - - - org.testcontainers - testcontainers-parent - 1.2.1-SNAPSHOT - ../../pom.xml - - - mysql - TestContainers :: JDBC :: MySQL - - - - ${project.groupId} - testcontainers - ${project.version} - - - ${project.groupId} - jdbc - ${project.version} - - - - - mysql - mysql-connector-java - 5.1.35 - test - - - - - com.zaxxer - HikariCP-java6 - 2.3.8 - test - - - commons-dbutils - commons-dbutils - 1.6 - test - - - org.apache.tomcat - tomcat-jdbc - 8.5.4 - - - org.vibur - vibur-dbcp - 9.0 - - - \ No newline at end of file diff --git a/modules/mysql/sql/init_mysql.sql b/modules/mysql/sql/init_mysql.sql new file mode 100644 index 00000000000..2b00ee968b0 --- /dev/null +++ b/modules/mysql/sql/init_mysql.sql @@ -0,0 +1,5 @@ +CREATE TABLE bar ( + foo VARCHAR(255) +); + +INSERT INTO bar (foo) VALUES ('hello world'); \ No newline at end of file diff --git a/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainer.java b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainer.java index 3f2332e0143..36315b2fe48 100644 --- a/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainer.java +++ b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainer.java @@ -1,59 +1,148 @@ package org.testcontainers.containers; +import org.jetbrains.annotations.NotNull; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +import java.util.Set; + /** - * @author richardnorth + * Testcontainers implementation for MySQL. + *

+ * Supported image: {@code mysql} + *

+ * Exposed ports: 3306 + * + * @deprecated use {@link org.testcontainers.mysql.MySQLContainer} instead. */ +@Deprecated public class MySQLContainer> extends JdbcDatabaseContainer { public static final String NAME = "mysql"; - public static final String IMAGE = "mysql"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("mysql"); + + @Deprecated + public static final String DEFAULT_TAG = "5.7.34"; + + @Deprecated + public static final String IMAGE = DEFAULT_IMAGE_NAME.getUnversionedPart(); + + static final String DEFAULT_USER = "test"; + + static final String DEFAULT_PASSWORD = "test"; + private static final String MY_CNF_CONFIG_OVERRIDE_PARAM_NAME = "TC_MY_CNF"; - private static final Integer MYSQL_PORT = 3306; + public static final Integer MYSQL_PORT = 3306; + + private String databaseName = "test"; + + private String username = DEFAULT_USER; + + private String password = DEFAULT_PASSWORD; + + private static final String MYSQL_ROOT_USER = "root"; + + /** + * @deprecated use {@link #MySQLContainer(DockerImageName)} instead + */ + @Deprecated public MySQLContainer() { - super(IMAGE + ":latest"); + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); } public MySQLContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public MySQLContainer(final DockerImageName dockerImageName) { super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + addExposedPort(MYSQL_PORT); } + /** + * @return the ports on which to check if the container is ready + * @deprecated use {@link #getLivenessCheckPortNumbers()} instead + */ + @NotNull @Override - protected Integer getLivenessCheckPort() { - return getMappedPort(MYSQL_PORT); + @Deprecated + protected Set getLivenessCheckPorts() { + return super.getLivenessCheckPorts(); } @Override protected void configure() { - optionallyMapResourceParameterAsVolume(MY_CNF_CONFIG_OVERRIDE_PARAM_NAME, "/etc/mysql/conf.d", "mysql-default-conf"); - - addExposedPort(3306); - addEnv("MYSQL_DATABASE", "test"); - addEnv("MYSQL_USER", "test"); - addEnv("MYSQL_PASSWORD", "test"); - addEnv("MYSQL_ROOT_PASSWORD", "test"); - setCommand("mysqld"); + optionallyMapResourceParameterAsVolume( + MY_CNF_CONFIG_OVERRIDE_PARAM_NAME, + "/etc/mysql/conf.d", + "mysql-default-conf", + Transferable.DEFAULT_DIR_MODE + ); + + addEnv("MYSQL_DATABASE", databaseName); + if (!MYSQL_ROOT_USER.equalsIgnoreCase(username)) { + addEnv("MYSQL_USER", username); + } + if (password != null && !password.isEmpty()) { + addEnv("MYSQL_PASSWORD", password); + addEnv("MYSQL_ROOT_PASSWORD", password); + } else if (MYSQL_ROOT_USER.equalsIgnoreCase(username)) { + addEnv("MYSQL_ALLOW_EMPTY_PASSWORD", "yes"); + } else { + throw new ContainerLaunchException("Empty password can be used only with the root user"); + } setStartupAttempts(3); } @Override public String getDriverClassName() { - return "com.mysql.jdbc.Driver"; + try { + Class.forName("com.mysql.cj.jdbc.Driver"); + return "com.mysql.cj.jdbc.Driver"; + } catch (ClassNotFoundException e) { + return "com.mysql.jdbc.Driver"; + } } @Override public String getJdbcUrl() { - return "jdbc:mysql://" + getContainerIpAddress() + ":" + getMappedPort(MYSQL_PORT) + "/test"; + String additionalUrlParams = constructUrlParameters("?", "&"); + return "jdbc:mysql://" + getHost() + ":" + getMappedPort(MYSQL_PORT) + "/" + databaseName + additionalUrlParams; + } + + @Override + protected String constructUrlForConnection(String queryString) { + String url = super.constructUrlForConnection(queryString); + + if (!url.contains("useSSL=")) { + String separator = url.contains("?") ? "&" : "?"; + url = url + separator + "useSSL=false"; + } + + if (!url.contains("allowPublicKeyRetrieval=")) { + url = url + "&allowPublicKeyRetrieval=true"; + } + + return url; + } + + @Override + public String getDatabaseName() { + return databaseName; } @Override public String getUsername() { - return "test"; + return username; } @Override public String getPassword() { - return "test"; + return password; } @Override @@ -65,4 +154,22 @@ public SELF withConfigurationOverride(String s) { parameters.put(MY_CNF_CONFIG_OVERRIDE_PARAM_NAME, s); return self(); } + + @Override + public SELF withDatabaseName(final String databaseName) { + this.databaseName = databaseName; + return self(); + } + + @Override + public SELF withUsername(final String username) { + this.username = username; + return self(); + } + + @Override + public SELF withPassword(final String password) { + this.password = password; + return self(); + } } diff --git a/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainerProvider.java b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainerProvider.java index f3e45673f05..621001d7b57 100644 --- a/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainerProvider.java +++ b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainerProvider.java @@ -1,16 +1,38 @@ package org.testcontainers.containers; +import org.testcontainers.jdbc.ConnectionUrl; +import org.testcontainers.utility.DockerImageName; + /** * Factory for MySQL containers. */ public class MySQLContainerProvider extends JdbcDatabaseContainerProvider { + + private static final String USER_PARAM = "user"; + + private static final String PASSWORD_PARAM = "password"; + @Override public boolean supports(String databaseType) { return databaseType.equals(MySQLContainer.NAME); } + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(MySQLContainer.DEFAULT_TAG); + } + @Override public JdbcDatabaseContainer newInstance(String tag) { - return new MySQLContainer(MySQLContainer.IMAGE + ":" + tag); + if (tag != null) { + return new MySQLContainer(DockerImageName.parse(MySQLContainer.IMAGE).withTag(tag)); + } else { + return newInstance(); + } + } + + @Override + public JdbcDatabaseContainer newInstance(ConnectionUrl connectionUrl) { + return newInstanceFromConnectionUrl(connectionUrl, USER_PARAM, PASSWORD_PARAM); } } diff --git a/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainer.java b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainer.java new file mode 100644 index 00000000000..4bbb542fec8 --- /dev/null +++ b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainer.java @@ -0,0 +1,35 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; + +@RequiredArgsConstructor +public class MySQLR2DBCDatabaseContainer implements R2DBCDatabaseContainer { + + @Delegate(types = Startable.class) + private final MySQLContainer container; + + public static ConnectionFactoryOptions getOptions(MySQLContainer container) { + ConnectionFactoryOptions options = ConnectionFactoryOptions + .builder() + .option(ConnectionFactoryOptions.DRIVER, MySQLR2DBCDatabaseContainerProvider.DRIVER) + .build(); + + return new MySQLR2DBCDatabaseContainer(container).configure(options); + } + + @Override + public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { + return options + .mutate() + .option(ConnectionFactoryOptions.HOST, container.getHost()) + .option(ConnectionFactoryOptions.PORT, container.getMappedPort(MySQLContainer.MYSQL_PORT)) + .option(ConnectionFactoryOptions.DATABASE, container.getDatabaseName()) + .option(ConnectionFactoryOptions.USER, container.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, container.getPassword()) + .build(); + } +} diff --git a/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerProvider.java b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerProvider.java new file mode 100644 index 00000000000..97f7f4a243d --- /dev/null +++ b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerProvider.java @@ -0,0 +1,44 @@ +package org.testcontainers.containers; + +import io.asyncer.r2dbc.mysql.MySqlConnectionFactoryProvider; +import io.r2dbc.spi.ConnectionFactoryMetadata; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; +import org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider; + +import javax.annotation.Nullable; + +public class MySQLR2DBCDatabaseContainerProvider implements R2DBCDatabaseContainerProvider { + + static final String DRIVER = MySqlConnectionFactoryProvider.MYSQL_DRIVER; + + @Override + public boolean supports(ConnectionFactoryOptions options) { + return DRIVER.equals(options.getRequiredValue(ConnectionFactoryOptions.DRIVER)); + } + + @Override + public R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options) { + String image = MySQLContainer.IMAGE + ":" + options.getRequiredValue(IMAGE_TAG_OPTION); + MySQLContainer container = new MySQLContainer<>(image) + .withDatabaseName((String) options.getRequiredValue(ConnectionFactoryOptions.DATABASE)); + + if (Boolean.TRUE.equals(options.getValue(REUSABLE_OPTION))) { + container.withReuse(true); + } + return new MySQLR2DBCDatabaseContainer(container); + } + + @Nullable + @Override + public ConnectionFactoryMetadata getMetadata(ConnectionFactoryOptions options) { + ConnectionFactoryOptions.Builder builder = options.mutate(); + if (!options.hasOption(ConnectionFactoryOptions.USER)) { + builder.option(ConnectionFactoryOptions.USER, MySQLContainer.DEFAULT_USER); + } + if (!options.hasOption(ConnectionFactoryOptions.PASSWORD)) { + builder.option(ConnectionFactoryOptions.PASSWORD, MySQLContainer.DEFAULT_PASSWORD); + } + return R2DBCDatabaseContainerProvider.super.getMetadata(builder.build()); + } +} diff --git a/modules/mysql/src/main/java/org/testcontainers/mysql/MySQLContainer.java b/modules/mysql/src/main/java/org/testcontainers/mysql/MySQLContainer.java new file mode 100644 index 00000000000..25f876c9911 --- /dev/null +++ b/modules/mysql/src/main/java/org/testcontainers/mysql/MySQLContainer.java @@ -0,0 +1,160 @@ +package org.testcontainers.mysql; + +import org.jetbrains.annotations.NotNull; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +import java.util.Set; + +/** + * Testcontainers implementation for MySQL. + *

+ * Supported image: {@code mysql} + *

+ * Exposed ports: 3306 + */ +public class MySQLContainer extends JdbcDatabaseContainer { + + public static final String NAME = "mysql"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("mysql"); + + static final String DEFAULT_USER = "test"; + + static final String DEFAULT_PASSWORD = "test"; + + private static final String MY_CNF_CONFIG_OVERRIDE_PARAM_NAME = "TC_MY_CNF"; + + public static final Integer MYSQL_PORT = 3306; + + private String databaseName = "test"; + + private String username = DEFAULT_USER; + + private String password = DEFAULT_PASSWORD; + + private static final String MYSQL_ROOT_USER = "root"; + + public MySQLContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public MySQLContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + addExposedPort(MYSQL_PORT); + } + + /** + * @return the ports on which to check if the container is ready + * @deprecated use {@link #getLivenessCheckPortNumbers()} instead + */ + @NotNull + @Override + @Deprecated + protected Set getLivenessCheckPorts() { + return super.getLivenessCheckPorts(); + } + + @Override + protected void configure() { + optionallyMapResourceParameterAsVolume( + MY_CNF_CONFIG_OVERRIDE_PARAM_NAME, + "/etc/mysql/conf.d", + null, + Transferable.DEFAULT_DIR_MODE + ); + + addEnv("MYSQL_DATABASE", databaseName); + if (!MYSQL_ROOT_USER.equalsIgnoreCase(username)) { + addEnv("MYSQL_USER", username); + } + if (password != null && !password.isEmpty()) { + addEnv("MYSQL_PASSWORD", password); + addEnv("MYSQL_ROOT_PASSWORD", password); + } else if (MYSQL_ROOT_USER.equalsIgnoreCase(username)) { + addEnv("MYSQL_ALLOW_EMPTY_PASSWORD", "yes"); + } else { + throw new ContainerLaunchException("Empty password can be used only with the root user"); + } + setStartupAttempts(3); + } + + @Override + public String getDriverClassName() { + try { + Class.forName("com.mysql.cj.jdbc.Driver"); + return "com.mysql.cj.jdbc.Driver"; + } catch (ClassNotFoundException e) { + return "com.mysql.jdbc.Driver"; + } + } + + @Override + public String getJdbcUrl() { + String additionalUrlParams = constructUrlParameters("?", "&"); + return "jdbc:mysql://" + getHost() + ":" + getMappedPort(MYSQL_PORT) + "/" + databaseName + additionalUrlParams; + } + + @Override + protected String constructUrlForConnection(String queryString) { + String url = super.constructUrlForConnection(queryString); + + if (!url.contains("useSSL=")) { + String separator = url.contains("?") ? "&" : "?"; + url = url + separator + "useSSL=false"; + } + + if (!url.contains("allowPublicKeyRetrieval=")) { + url = url + "&allowPublicKeyRetrieval=true"; + } + + return url; + } + + @Override + public String getDatabaseName() { + return databaseName; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getTestQueryString() { + return "SELECT 1"; + } + + public MySQLContainer withConfigurationOverride(String s) { + parameters.put(MY_CNF_CONFIG_OVERRIDE_PARAM_NAME, s); + return self(); + } + + @Override + public MySQLContainer withDatabaseName(final String databaseName) { + this.databaseName = databaseName; + return self(); + } + + @Override + public MySQLContainer withUsername(final String username) { + this.username = username; + return self(); + } + + @Override + public MySQLContainer withPassword(final String password) { + this.password = password; + return self(); + } +} diff --git a/modules/mysql/src/main/java/org/testcontainers/mysql/MySQLR2DBCDatabaseContainer.java b/modules/mysql/src/main/java/org/testcontainers/mysql/MySQLR2DBCDatabaseContainer.java new file mode 100644 index 00000000000..d2a272559d9 --- /dev/null +++ b/modules/mysql/src/main/java/org/testcontainers/mysql/MySQLR2DBCDatabaseContainer.java @@ -0,0 +1,58 @@ +package org.testcontainers.mysql; + +import io.asyncer.r2dbc.mysql.MySqlConnectionFactoryProvider; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; + +import java.util.Set; + +public class MySQLR2DBCDatabaseContainer implements R2DBCDatabaseContainer { + + private final MySQLContainer container; + + public MySQLR2DBCDatabaseContainer(MySQLContainer container) { + this.container = container; + } + + public static ConnectionFactoryOptions getOptions(MySQLContainer container) { + ConnectionFactoryOptions options = ConnectionFactoryOptions + .builder() + .option(ConnectionFactoryOptions.DRIVER, MySqlConnectionFactoryProvider.MYSQL_DRIVER) + .build(); + + return new MySQLR2DBCDatabaseContainer(container).configure(options); + } + + @Override + public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { + return options + .mutate() + .option(ConnectionFactoryOptions.HOST, container.getHost()) + .option(ConnectionFactoryOptions.PORT, container.getMappedPort(MySQLContainer.MYSQL_PORT)) + .option(ConnectionFactoryOptions.DATABASE, container.getDatabaseName()) + .option(ConnectionFactoryOptions.USER, container.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, container.getPassword()) + .build(); + } + + @Override + public Set getDependencies() { + return this.container.getDependencies(); + } + + @Override + public void start() { + this.container.start(); + } + + @Override + public void stop() { + this.container.stop(); + } + + @Override + public void close() { + this.container.close(); + } +} diff --git a/modules/mysql/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider b/modules/mysql/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider new file mode 100644 index 00000000000..88cab78dc2e --- /dev/null +++ b/modules/mysql/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.containers.MySQLR2DBCDatabaseContainerProvider diff --git a/modules/mysql/src/main/resources/mysql-default-conf/my.cnf b/modules/mysql/src/main/resources/mysql-default-conf/my.cnf index b0dce6488c3..b22aa7d9e93 100644 --- a/modules/mysql/src/main/resources/mysql-default-conf/my.cnf +++ b/modules/mysql/src/main/resources/mysql-default-conf/my.cnf @@ -11,8 +11,7 @@ sort_buffer_size = 64K read_buffer_size = 256K read_rnd_buffer_size = 256K net_buffer_length = 2K -thread_stack = 128K -skip-host-cache +host_cache_size = 0 skip-name-resolve # Don't listen on a TCP/IP port at all. This can be a security enhancement, @@ -44,7 +43,6 @@ innodb_data_file_path = ibdata1:10M:autoextend innodb_buffer_pool_size = 16M #innodb_additional_mem_pool_size = 2M # Set .._log_file_size to 25 % of buffer pool size -innodb_log_file_size = 5M innodb_log_buffer_size = 8M innodb_flush_log_at_trx_commit = 1 -innodb_lock_wait_timeout = 50 \ No newline at end of file +innodb_lock_wait_timeout = 50 diff --git a/modules/mysql/src/test/java/org/testcontainers/MySQLTestImages.java b/modules/mysql/src/test/java/org/testcontainers/MySQLTestImages.java new file mode 100644 index 00000000000..6b3fe80ce4a --- /dev/null +++ b/modules/mysql/src/test/java/org/testcontainers/MySQLTestImages.java @@ -0,0 +1,14 @@ +package org.testcontainers; + +import org.testcontainers.utility.DockerImageName; + +public class MySQLTestImages { + + public static final DockerImageName MYSQL_57_IMAGE = DockerImageName.parse("mysql:5.7.44"); + + public static final DockerImageName MYSQL_80_IMAGE = DockerImageName.parse("mysql:8.0.36"); + + public static final DockerImageName MYSQL_INNOVATION_IMAGE = DockerImageName.parse("mysql:8.3.0"); + + public static final DockerImageName MYSQL_93_IMAGE = DockerImageName.parse("mysql:9.3.0"); +} diff --git a/modules/mysql/src/test/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerTest.java b/modules/mysql/src/test/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..42a852ad1f1 --- /dev/null +++ b/modules/mysql/src/test/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerTest.java @@ -0,0 +1,23 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.MySQLTestImages; +import org.testcontainers.r2dbc.AbstractR2DBCDatabaseContainerTest; + +public class MySQLR2DBCDatabaseContainerTest extends AbstractR2DBCDatabaseContainerTest> { + + @Override + protected ConnectionFactoryOptions getOptions(MySQLContainer container) { + return MySQLR2DBCDatabaseContainer.getOptions(container); + } + + @Override + protected String createR2DBCUrl() { + return "r2dbc:tc:mysql:///db?TC_IMAGE_TAG=" + MySQLTestImages.MYSQL_80_IMAGE.getVersionPart(); + } + + @Override + protected MySQLContainer createContainer() { + return new MySQLContainer<>(MySQLTestImages.MYSQL_80_IMAGE); + } +} diff --git a/modules/mysql/src/test/java/org/testcontainers/containers/MySQLRootAccountTest.java b/modules/mysql/src/test/java/org/testcontainers/containers/MySQLRootAccountTest.java new file mode 100644 index 00000000000..a8737c4c74f --- /dev/null +++ b/modules/mysql/src/test/java/org/testcontainers/containers/MySQLRootAccountTest.java @@ -0,0 +1,54 @@ +package org.testcontainers.containers; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.MySQLTestImages; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.utility.DockerImageName; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +@Slf4j +class MySQLRootAccountTest { + + public static DockerImageName[] params() { + return new DockerImageName[] { + MySQLTestImages.MYSQL_57_IMAGE, + MySQLTestImages.MYSQL_80_IMAGE, + MySQLTestImages.MYSQL_INNOVATION_IMAGE, + MySQLTestImages.MYSQL_93_IMAGE, + }; + } + + @ParameterizedTest + @MethodSource("params") + void testRootAccountUsageWithDefaultPassword(DockerImageName image) throws SQLException { + testWithDB(new MySQLContainer<>(image).withUsername("root")); + } + + @ParameterizedTest + @MethodSource("params") + void testRootAccountUsageWithEmptyPassword(DockerImageName image) throws SQLException { + testWithDB(new MySQLContainer<>(image).withUsername("root").withPassword("")); + } + + @ParameterizedTest + @MethodSource("params") + void testRootAccountUsageWithCustomPassword(DockerImageName image) throws SQLException { + testWithDB(new MySQLContainer<>(image).withUsername("root").withPassword("not-default")); + } + + private void testWithDB(MySQLContainer db) throws SQLException { + try { + db.withLogConsumer(new Slf4jLogConsumer(log)).start(); + Connection connection = DriverManager.getConnection(db.getJdbcUrl(), db.getUsername(), db.getPassword()); + connection.createStatement().execute("SELECT 1"); + connection.createStatement().execute("set sql_log_bin=0"); // requires root + } finally { + db.close(); + } + } +} diff --git a/modules/jdbc-test/src/test/java/org/testcontainers/jdbc/JDBCDriverWithPoolTest.java b/modules/mysql/src/test/java/org/testcontainers/jdbc/mysql/JDBCDriverWithPoolTest.java similarity index 61% rename from modules/jdbc-test/src/test/java/org/testcontainers/jdbc/JDBCDriverWithPoolTest.java rename to modules/mysql/src/test/java/org/testcontainers/jdbc/mysql/JDBCDriverWithPoolTest.java index 4c7e119996d..0075b2ebf7f 100644 --- a/modules/jdbc-test/src/test/java/org/testcontainers/jdbc/JDBCDriverWithPoolTest.java +++ b/modules/mysql/src/test/java/org/testcontainers/jdbc/mysql/JDBCDriverWithPoolTest.java @@ -1,44 +1,51 @@ -package org.testcontainers.jdbc; +package org.testcontainers.jdbc.mysql; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import org.apache.commons.dbutils.QueryRunner; import org.apache.commons.dbutils.ResultSetHandler; import org.apache.tomcat.jdbc.pool.PoolProperties; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.jdbc.ContainerDatabaseDriver; import org.vibur.dbcp.ViburDBCPDataSource; -import javax.sql.DataSource; import java.sql.Connection; import java.sql.SQLException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; +import java.util.stream.Stream; + +import javax.sql.DataSource; -import static java.util.Arrays.asList; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; /** - * + * This test belongs in the jdbc module, as it is focused on testing the behaviour of {@link org.testcontainers.containers.JdbcDatabaseContainer}. + * However, the need to use the {@link org.testcontainers.containers.MySQLContainerProvider} (due to the jdbc:tc:mysql) URL forces it to live here in + * the mysql module, to avoid circular dependencies. + * TODO: Move to the jdbc module and either (a) implement a barebones {@link org.testcontainers.containers.JdbcDatabaseContainerProvider} for testing, or (b) refactor into a unit test. */ -@RunWith(Parameterized.class) +@ParameterizedClass +@MethodSource("dataSourceSuppliers") public class JDBCDriverWithPoolTest { - public static final String URL = "jdbc:tc:mysql://hostname/databasename?TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverWithPoolTest::sampleInitFunction"; + public static final String URL = + "jdbc:tc:mysql:8.0.36://hostname/databasename?TC_INITFUNCTION=org.testcontainers.jdbc.mysql.JDBCDriverWithPoolTest::sampleInitFunction"; + private final DataSource dataSource; - @Parameterized.Parameters - public static Iterable> dataSourceSuppliers() { - return asList( - JDBCDriverWithPoolTest::getTomcatDataSourceWithDriverClassName, - JDBCDriverWithPoolTest::getTomcatDataSource, - JDBCDriverWithPoolTest::getHikariDataSourceWithDriverClassName, - JDBCDriverWithPoolTest::getHikariDataSource, - JDBCDriverWithPoolTest::getViburDataSourceWithDriverClassName, - JDBCDriverWithPoolTest::getViburDataSource + public static Stream> dataSourceSuppliers() { + return Stream.of( + JDBCDriverWithPoolTest::getTomcatDataSourceWithDriverClassName, + JDBCDriverWithPoolTest::getTomcatDataSource, + JDBCDriverWithPoolTest::getHikariDataSourceWithDriverClassName, + JDBCDriverWithPoolTest::getHikariDataSource, + JDBCDriverWithPoolTest::getViburDataSourceWithDriverClassName, + JDBCDriverWithPoolTest::getViburDataSource ); } @@ -49,14 +56,13 @@ public JDBCDriverWithPoolTest(Supplier dataSourceSupplier) { private ExecutorService executorService = Executors.newFixedThreadPool(5); @Test - public void testMySQLWithConnectionPoolUsingSameContainer() throws SQLException, InterruptedException { - + void testMySQLWithConnectionPoolUsingSameContainer() throws SQLException, InterruptedException { // Populate the database with some data in multiple threads, so that multiple connections from the pool will be used for (int i = 0; i < 100; i++) { executorService.submit(() -> { try { - new QueryRunner(dataSource).insert("INSERT INTO my_counter (n) VALUES (5)", - (ResultSetHandler) rs -> true); + new QueryRunner(dataSource) + .insert("INSERT INTO my_counter (n) VALUES (5)", (ResultSetHandler) rs -> true); } catch (SQLException e) { e.printStackTrace(); } @@ -68,22 +74,28 @@ public void testMySQLWithConnectionPoolUsingSameContainer() throws SQLException, executorService.awaitTermination(5, TimeUnit.MINUTES); // compare to expected results - int count = new QueryRunner(dataSource).query("SELECT COUNT(1) FROM my_counter", rs -> { - rs.next(); - return rs.getInt(1); - }); - assertEquals("Reuse of a datasource points to the same DB container", 100, count); - - - int sum = new QueryRunner(dataSource).query("SELECT SUM(n) FROM my_counter", rs -> { - rs.next(); - return rs.getInt(1); - }); + int count = new QueryRunner(dataSource) + .query( + "SELECT COUNT(1) FROM my_counter", + rs -> { + rs.next(); + return rs.getInt(1); + } + ); + assertThat(count).as("Reuse of a datasource points to the same DB container").isEqualTo(100); + + int sum = new QueryRunner(dataSource) + .query( + "SELECT SUM(n) FROM my_counter", + rs -> { + rs.next(); + return rs.getInt(1); + } + ); // 100 records * 5 = 500 expected - assertEquals("Reuse of a datasource points to the same DB container", 500, sum); + assertThat(sum).as("Reuse of a datasource points to the same DB container").isEqualTo(500); } - private static DataSource getTomcatDataSourceWithDriverClassName() { PoolProperties poolProperties = new PoolProperties(); poolProperties.setUrl(URL + ";TEST=TOMCAT_WITH_CLASSNAME"); // append a dummy URL element to ensure different DB per test @@ -130,6 +142,8 @@ private static DataSource getViburDataSourceWithDriverClassName() { ViburDBCPDataSource ds = new ViburDBCPDataSource(); ds.setJdbcUrl(URL + ";TEST=VIBUR_WITH_CLASSNAME"); + ds.setUsername("any"); // Recent versions of Vibur require a username, even though it will not be used + ds.setPassword(""); ds.setPoolInitialSize(3); ds.setPoolMaxSize(10); ds.setTestConnectionQuery("SELECT 1"); @@ -143,6 +157,8 @@ private static DataSource getViburDataSourceWithDriverClassName() { private static DataSource getViburDataSource() { ViburDBCPDataSource ds = new ViburDBCPDataSource(); ds.setJdbcUrl(URL + ";TEST=VIBUR"); + ds.setUsername("any"); // Recent versions of Vibur require a username, even though it will not be used + ds.setPassword(""); ds.setPoolInitialSize(3); ds.setPoolMaxSize(10); ds.setTestConnectionQuery("SELECT 1"); @@ -154,12 +170,8 @@ private static DataSource getViburDataSource() { @SuppressWarnings("SqlNoDataSourceInspection") public static void sampleInitFunction(Connection connection) throws SQLException { - connection.createStatement().execute("CREATE TABLE bar (\n" + - " foo VARCHAR(255)\n" + - ");"); + connection.createStatement().execute("CREATE TABLE bar (\n" + " foo VARCHAR(255)\n" + ");"); connection.createStatement().execute("INSERT INTO bar (foo) VALUES ('hello world');"); - connection.createStatement().execute("CREATE TABLE my_counter (\n" + - " n INT\n" + - ");"); + connection.createStatement().execute("CREATE TABLE my_counter (\n" + " n INT\n" + ");"); } } diff --git a/modules/mysql/src/test/java/org/testcontainers/jdbc/mysql/MySQLDatabaseContainerDriverTest.java b/modules/mysql/src/test/java/org/testcontainers/jdbc/mysql/MySQLDatabaseContainerDriverTest.java new file mode 100644 index 00000000000..da73f21ae2e --- /dev/null +++ b/modules/mysql/src/test/java/org/testcontainers/jdbc/mysql/MySQLDatabaseContainerDriverTest.java @@ -0,0 +1,44 @@ +package org.testcontainers.jdbc.mysql; + +import org.junit.jupiter.api.Test; +import org.testcontainers.jdbc.ContainerDatabaseDriver; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +class MySQLDatabaseContainerDriverTest { + + @Test + void shouldRespectBothUrlPropertiesAndParameterProperties() throws SQLException { + ContainerDatabaseDriver driver = new ContainerDatabaseDriver(); + String url = "jdbc:tc:mysql:8.0.36://hostname/databasename?padCharsWithSpace=true"; + Properties properties = new Properties(); + properties.setProperty("maxRows", "1"); + + try (Connection connection = driver.connect(url, properties)) { + try (Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE arbitrary_table (length_5_string CHAR(5))"); + statement.execute("INSERT INTO arbitrary_table VALUES ('abc')"); + statement.execute("INSERT INTO arbitrary_table VALUES ('123')"); + + // Check that maxRows is set + try (ResultSet resultSet = statement.executeQuery("SELECT * FROM arbitrary_table")) { + resultSet.next(); + assertThat(resultSet.isFirst()).isTrue(); + assertThat(resultSet.isLast()).isTrue(); + } + + // Check that pad with chars is set + try (ResultSet resultSet = statement.executeQuery("SELECT * FROM arbitrary_table")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getString(1)).isEqualTo("abc "); + } + } + } + } +} diff --git a/modules/mysql/src/test/java/org/testcontainers/jdbc/mysql/MySQLJDBCDriverTest.java b/modules/mysql/src/test/java/org/testcontainers/jdbc/mysql/MySQLJDBCDriverTest.java new file mode 100644 index 00000000000..b069e5fd0c8 --- /dev/null +++ b/modules/mysql/src/test/java/org/testcontainers/jdbc/mysql/MySQLJDBCDriverTest.java @@ -0,0 +1,47 @@ +package org.testcontainers.jdbc.mysql; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +class MySQLJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { + { "jdbc:tc:mysql://hostname/databasename", EnumSet.noneOf(Options.class) }, + { + "jdbc:tc:mysql://hostname/databasename?user=someuser&TC_INITSCRIPT=somepath/init_mysql.sql", + EnumSet.of(Options.ScriptedSchema, Options.JDBCParams), + }, + { + "jdbc:tc:mysql:8.0.36://hostname/databasename?user=someuser&TC_INITFUNCTION=org.testcontainers.jdbc.AbstractJDBCDriverTest::sampleInitFunction", + EnumSet.of(Options.ScriptedSchema, Options.JDBCParams), + }, + { + "jdbc:tc:mysql:8.0.36://hostname/databasename?user=someuser&password=somepwd&TC_INITSCRIPT=somepath/init_mysql.sql", + EnumSet.of(Options.ScriptedSchema, Options.JDBCParams), + }, + { + "jdbc:tc:mysql:8.0.36://hostname/databasename?user=someuser&password=somepwd&TC_INITSCRIPT=file:sql/init_mysql.sql", + EnumSet.of(Options.ScriptedSchema, Options.JDBCParams), + }, + { + "jdbc:tc:mysql:8.0.36://hostname/databasename?user=someuser&password=somepwd&TC_INITFUNCTION=org.testcontainers.jdbc.AbstractJDBCDriverTest::sampleInitFunction", + EnumSet.of(Options.ScriptedSchema, Options.JDBCParams), + }, + { + "jdbc:tc:mysql:8.0.36://hostname/databasename?TC_INITSCRIPT=somepath/init_unicode_mysql.sql&useUnicode=yes&characterEncoding=utf8", + EnumSet.of(Options.CharacterSet), + }, + { "jdbc:tc:mysql:8.0.36://hostname/databasename", EnumSet.noneOf(Options.class) }, + { "jdbc:tc:mysql:8.0.36://hostname/databasename?useSSL=false", EnumSet.noneOf(Options.class) }, + { + "jdbc:tc:mysql:8.0.36://hostname/databasename?TC_MY_CNF=somepath/mysql_conf_override", + EnumSet.of(Options.CustomIniFile), + }, + } + ); + } +} diff --git a/modules/mysql/src/test/java/org/testcontainers/mysql/MultiVersionMySQLTest.java b/modules/mysql/src/test/java/org/testcontainers/mysql/MultiVersionMySQLTest.java new file mode 100644 index 00000000000..8dc4454860b --- /dev/null +++ b/modules/mysql/src/test/java/org/testcontainers/mysql/MultiVersionMySQLTest.java @@ -0,0 +1,38 @@ +package org.testcontainers.mysql; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.MySQLTestImages; +import org.testcontainers.db.AbstractContainerDatabaseTest; +import org.testcontainers.utility.DockerImageName; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +class MultiVersionMySQLTest extends AbstractContainerDatabaseTest { + + public static DockerImageName[] params() { + return new DockerImageName[] { + MySQLTestImages.MYSQL_57_IMAGE, + MySQLTestImages.MYSQL_80_IMAGE, + MySQLTestImages.MYSQL_INNOVATION_IMAGE, + MySQLTestImages.MYSQL_93_IMAGE, + }; + } + + @ParameterizedTest + @MethodSource("params") + void versionCheckTest(DockerImageName dockerImageName) throws SQLException { + try (MySQLContainer mysql = new MySQLContainer(dockerImageName)) { + mysql.start(); + final ResultSet resultSet = performQuery(mysql, "SELECT VERSION()"); + final String resultSetString = resultSet.getString(1); + + assertThat(resultSetString) + .as("The database version can be set using a container rule parameter") + .isEqualTo(dockerImageName.getVersionPart()); + } + } +} diff --git a/modules/mysql/src/test/java/org/testcontainers/mysql/MySQLContainerTest.java b/modules/mysql/src/test/java/org/testcontainers/mysql/MySQLContainerTest.java new file mode 100644 index 00000000000..ad78781db5c --- /dev/null +++ b/modules/mysql/src/test/java/org/testcontainers/mysql/MySQLContainerTest.java @@ -0,0 +1,286 @@ +package org.testcontainers.mysql; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.MySQLTestImages; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.db.AbstractContainerDatabaseTest; + +import java.io.File; +import java.net.URL; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.attribute.PosixFilePermission; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +class MySQLContainerTest extends AbstractContainerDatabaseTest { + + private static final Logger logger = LoggerFactory.getLogger(MySQLContainerTest.class); + + @Test + void testSimple() throws SQLException { + try ( // container { + MySQLContainer mysql = new MySQLContainer("mysql:8.0.36") + // } + ) { + mysql.start(); + + ResultSet resultSet = performQuery(mysql, "SELECT 1"); + int resultSetInt = resultSet.getInt(1); + + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + assertHasCorrectExposedAndLivenessCheckPorts(mysql); + } + } + + @Test + void testSpecificVersion() throws SQLException { + try ( + MySQLContainer mysqlOldVersion = new MySQLContainer(MySQLTestImages.MYSQL_80_IMAGE) + .withConfigurationOverride("somepath/mysql_conf_override") + .withLogConsumer(new Slf4jLogConsumer(logger)) + ) { + mysqlOldVersion.start(); + + ResultSet resultSet = performQuery(mysqlOldVersion, "SELECT VERSION()"); + String resultSetString = resultSet.getString(1); + + assertThat(resultSetString) + .as("The database version can be set using a container rule parameter") + .startsWith("8.0"); + } + } + + @Test + void testMySQLWithCustomIniFile() throws SQLException { + try ( + MySQLContainer mysqlCustomConfig = new MySQLContainer(MySQLTestImages.MYSQL_80_IMAGE) + .withConfigurationOverride("somepath/mysql_conf_override") + ) { + mysqlCustomConfig.start(); + + assertThatCustomIniFileWasUsed(mysqlCustomConfig); + } + } + + @Test + void testCommandOverride() throws SQLException { + try ( + MySQLContainer mysqlCustomConfig = new MySQLContainer(MySQLTestImages.MYSQL_80_IMAGE) + .withCommand("mysqld --auto_increment_increment=42") + ) { + mysqlCustomConfig.start(); + + ResultSet resultSet = performQuery(mysqlCustomConfig, "show variables like 'auto_increment_increment'"); + String result = resultSet.getString("Value"); + + assertThat(result).as("Auto increment increment should be overridden by command line").isEqualTo("42"); + } + } + + @Test + void testExplicitInitScript() throws SQLException { + try ( + MySQLContainer container = new MySQLContainer(MySQLTestImages.MYSQL_80_IMAGE) + .withInitScript("somepath/init_mysql.sql") + .withLogConsumer(new Slf4jLogConsumer(logger)) + ) { + container.start(); + + ResultSet resultSet = performQuery(container, "SELECT foo FROM bar"); + String firstColumnValue = resultSet.getString(1); + + assertThat(firstColumnValue).as("Value from init script should equal real value").isEqualTo("hello world"); + } + } + + @Test + void testEmptyPasswordWithNonRootUser() { + try ( + MySQLContainer container = new MySQLContainer(MySQLTestImages.MYSQL_80_IMAGE) + .withDatabaseName("TEST") + .withUsername("test") + .withPassword("") + .withEnv("MYSQL_ROOT_HOST", "%") + ) { + assertThatThrownBy(container::start) + .isInstanceOf(ContainerLaunchException.class) + .hasMessageStartingWith("Container startup failed for image mysql"); + } + } + + @Test + void testEmptyPasswordWithRootUser() throws SQLException { + // Add MYSQL_ROOT_HOST environment so that we can root login from anywhere for testing purposes + try ( + MySQLContainer mysql = new MySQLContainer(MySQLTestImages.MYSQL_80_IMAGE) + .withDatabaseName("foo") + .withUsername("root") + .withPassword("") + .withEnv("MYSQL_ROOT_HOST", "%") + ) { + mysql.start(); + + ResultSet resultSet = performQuery(mysql, "SELECT 1"); + int resultSetInt = resultSet.getInt(1); + + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + } + } + + @Test + void testWithAdditionalUrlParamTimeZone() throws SQLException { + MySQLContainer mysql = new MySQLContainer(MySQLTestImages.MYSQL_80_IMAGE) + .withUrlParam("serverTimezone", "Europe/Zurich") + .withEnv("TZ", "Europe/Zurich") + .withLogConsumer(new Slf4jLogConsumer(logger)); + mysql.start(); + + try (Connection connection = mysql.createConnection("")) { + Statement statement = connection.createStatement(); + statement.execute("SELECT NOW();"); + try (ResultSet resultSet = statement.getResultSet()) { + resultSet.next(); + + // checking that the time_zone MySQL is Europe/Zurich + LocalDateTime localDateTime = resultSet.getObject(1, LocalDateTime.class); + ZonedDateTime actualDateTime = localDateTime + .atZone(ZoneId.of("Europe/Zurich")) + .truncatedTo(ChronoUnit.MINUTES); + ZonedDateTime expectedDateTime = ZonedDateTime + .now(ZoneId.of("Europe/Zurich")) + .truncatedTo(ChronoUnit.MINUTES); + + String message = String.format( + "MySQL time zone is not Europe/Zurich. MySQL date:%s, current date:%s", + actualDateTime, + expectedDateTime + ); + assertThat(actualDateTime).as(message).isEqualTo(expectedDateTime); + } + } finally { + mysql.stop(); + } + } + + @Test + void testWithAdditionalUrlParamMultiQueries() throws SQLException { + MySQLContainer mysql = new MySQLContainer(MySQLTestImages.MYSQL_80_IMAGE) + .withUrlParam("allowMultiQueries", "true") + .withLogConsumer(new Slf4jLogConsumer(logger)); + mysql.start(); + + try (Connection connection = mysql.createConnection("")) { + Statement statement = connection.createStatement(); + String multiQuery = + "DROP TABLE IF EXISTS bar; " + + "CREATE TABLE bar (foo VARCHAR(20)); " + + "INSERT INTO bar (foo) VALUES ('hello world');"; + statement.execute(multiQuery); + statement.execute("SELECT foo FROM bar;"); + try (ResultSet resultSet = statement.getResultSet()) { + resultSet.next(); + String firstColumnValue = resultSet.getString(1); + assertThat(firstColumnValue).as("Value from bar should equal real value").isEqualTo("hello world"); + } + } finally { + mysql.stop(); + } + } + + @Test + void testWithAdditionalUrlParamInJdbcUrl() { + MySQLContainer mysql = new MySQLContainer(MySQLTestImages.MYSQL_80_IMAGE) + .withUrlParam("allowMultiQueries", "true") + .withUrlParam("rewriteBatchedStatements", "true") + .withLogConsumer(new Slf4jLogConsumer(logger)); + + try { + mysql.start(); + String jdbcUrl = mysql.getJdbcUrl(); + assertThat(jdbcUrl).contains("?"); + assertThat(jdbcUrl).contains("&"); + assertThat(jdbcUrl).contains("rewriteBatchedStatements=true"); + assertThat(jdbcUrl).contains("allowMultiQueries=true"); + } finally { + mysql.stop(); + } + } + + @Test + void testWithOnlyUserReadableCustomIniFile() throws Exception { + assumeThat(FileSystems.getDefault().supportedFileAttributeViews().contains("posix")).isTrue(); + try ( + MySQLContainer mysql = new MySQLContainer(MySQLTestImages.MYSQL_80_IMAGE) + .withConfigurationOverride("somepath/mysql_conf_override") + .withLogConsumer(new Slf4jLogConsumer(logger)) + ) { + URL resource = this.getClass().getClassLoader().getResource("somepath/mysql_conf_override"); + + File file = new File(resource.toURI()); + assertThat(file.isDirectory()).isTrue(); + + Set permissions = new HashSet<>( + Arrays.asList( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE + ) + ); + + Files.setPosixFilePermissions(file.toPath(), permissions); + + mysql.start(); + assertThatCustomIniFileWasUsed(mysql); + } + } + + @Test + void testCustom() throws SQLException { + // Add MYSQL_ROOT_HOST environment so that we can root login from anywhere for testing purposes + try ( + MySQLContainer mysql = new MySQLContainer(MySQLTestImages.MYSQL_80_IMAGE) + .withDatabaseName("foo") + .withUsername("bar") + .withPassword("baz") + .withEnv("MYSQL_ROOT_HOST", "%") + ) { + mysql.start(); + + ResultSet resultSet = performQuery(mysql, "SELECT 1"); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + } + } + + private void assertHasCorrectExposedAndLivenessCheckPorts(MySQLContainer mysql) { + assertThat(mysql.getExposedPorts()).containsExactly(MySQLContainer.MYSQL_PORT); + assertThat(mysql.getLivenessCheckPortNumbers()).containsExactly(mysql.getMappedPort(MySQLContainer.MYSQL_PORT)); + } + + private void assertThatCustomIniFileWasUsed(MySQLContainer mysql) throws SQLException { + try (ResultSet resultSet = performQuery(mysql, "SELECT @@GLOBAL.innodb_max_undo_log_size")) { + long result = resultSet.getLong(1); + assertThat(result) + .as("The InnoDB max undo log size has been set by the ini file content") + .isEqualTo(20000000); + } + } +} diff --git a/modules/mysql/src/test/java/org/testcontainers/mysql/MySQLR2DBCDatabaseContainerTest.java b/modules/mysql/src/test/java/org/testcontainers/mysql/MySQLR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..5e86a92347b --- /dev/null +++ b/modules/mysql/src/test/java/org/testcontainers/mysql/MySQLR2DBCDatabaseContainerTest.java @@ -0,0 +1,23 @@ +package org.testcontainers.mysql; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.MySQLTestImages; +import org.testcontainers.r2dbc.AbstractR2DBCDatabaseContainerTest; + +public class MySQLR2DBCDatabaseContainerTest extends AbstractR2DBCDatabaseContainerTest { + + @Override + protected ConnectionFactoryOptions getOptions(MySQLContainer container) { + return MySQLR2DBCDatabaseContainer.getOptions(container); + } + + @Override + protected String createR2DBCUrl() { + return "r2dbc:tc:mysql:///db?TC_IMAGE_TAG=" + MySQLTestImages.MYSQL_80_IMAGE.getVersionPart(); + } + + @Override + protected MySQLContainer createContainer() { + return new MySQLContainer(MySQLTestImages.MYSQL_80_IMAGE); + } +} diff --git a/modules/mysql/src/test/resources/logback-test.xml b/modules/mysql/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/mysql/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/mysql/src/test/resources/somepath/init_mysql.sql b/modules/mysql/src/test/resources/somepath/init_mysql.sql new file mode 100644 index 00000000000..addede19dd7 --- /dev/null +++ b/modules/mysql/src/test/resources/somepath/init_mysql.sql @@ -0,0 +1,45 @@ +CREATE TABLE bar ( + foo VARCHAR(255) +); + +DROP PROCEDURE IF EXISTS -- ; + count_foo; + +SELECT "a /* string literal containing comment characters like -- here"; +SELECT "a 'quoting' \"scenario ` involving BEGIN keyword\" here"; +SELECT * from `bar`; + +-- What about a line comment containing imbalanced string delimiters? " + +CREATE PROCEDURE count_foo() + BEGIN + + BEGIN + SELECT * + FROM bar; + SELECT 1 + FROM dual; + END; + + BEGIN + select * from bar; + END; + + -- we can do comments + + /* including block + comments + */ + + /* what if BEGIN appears inside a comment? */ + + select "or what if BEGIN appears inside a literal?"; + + END /*; */; + +/* or a block comment + containing imbalanced string delimiters? + ' " + */ + +INSERT INTO bar (foo) /* ; */ VALUES ('hello world'); diff --git a/modules/mysql/src/test/resources/somepath/init_unicode_mysql.sql b/modules/mysql/src/test/resources/somepath/init_unicode_mysql.sql new file mode 100644 index 00000000000..f88969b0f30 --- /dev/null +++ b/modules/mysql/src/test/resources/somepath/init_unicode_mysql.sql @@ -0,0 +1,5 @@ +CREATE TABLE bar ( + foo varchar(255) character set utf8 +); + +INSERT INTO bar (foo) VALUES ('привет мир'); diff --git a/modules/jdbc-test/src/test/resources/somepath/mysql_conf_override/my.cnf b/modules/mysql/src/test/resources/somepath/mysql_conf_override/my.cnf similarity index 88% rename from modules/jdbc-test/src/test/resources/somepath/mysql_conf_override/my.cnf rename to modules/mysql/src/test/resources/somepath/mysql_conf_override/my.cnf index 220060976ab..6e6dcd28ad1 100644 --- a/modules/jdbc-test/src/test/resources/somepath/mysql_conf_override/my.cnf +++ b/modules/mysql/src/test/resources/somepath/mysql_conf_override/my.cnf @@ -1,4 +1,6 @@ [mysqld] +user = mysql +datadir = /var/lib/mysql port = 3306 #socket = /tmp/mysql.sock skip-external-locking @@ -9,14 +11,11 @@ sort_buffer_size = 64K read_buffer_size = 256K read_rnd_buffer_size = 256K net_buffer_length = 2K -thread_stack = 128K - - -innodb_file_format=Barracuda - - - +host_cache_size = 0 +skip-name-resolve +# This configuration is custom to test whether config override works +innodb_max_undo_log_size = 20000000 # Don't listen on a TCP/IP port at all. This can be a security enhancement, # if all processes that need to connect to mysqld run on the same host. @@ -47,7 +46,6 @@ innodb_data_file_path = ibdata1:10M:autoextend innodb_buffer_pool_size = 16M #innodb_additional_mem_pool_size = 2M # Set .._log_file_size to 25 % of buffer pool size -innodb_log_file_size = 5M innodb_log_buffer_size = 8M innodb_flush_log_at_trx_commit = 1 -innodb_lock_wait_timeout = 50 \ No newline at end of file +innodb_lock_wait_timeout = 50 diff --git a/modules/neo4j/.gitignore b/modules/neo4j/.gitignore new file mode 100644 index 00000000000..486b6e376b8 --- /dev/null +++ b/modules/neo4j/.gitignore @@ -0,0 +1 @@ +container-license-acceptance.txt diff --git a/modules/neo4j/build.gradle b/modules/neo4j/build.gradle new file mode 100644 index 00000000000..96f73b3bb81 --- /dev/null +++ b/modules/neo4j/build.gradle @@ -0,0 +1,37 @@ +description = "Testcontainers :: Neo4j" + +def generatedResourcesDir = new File(project.buildDir, "generated-resources") +def customNeo4jPluginDestinationDir = new File(generatedResourcesDir, "custom-plugins") + +sourceSets { + customNeo4jPlugin { + java { + srcDir 'src/custom-neo4j-plugin' + } + } + test { + resources { + srcDir generatedResourcesDir + } + } +} + +task customNeo4jPluginJar(type: Jar) { + from sourceSets.customNeo4jPlugin.output + archiveFileName = "hello-world.jar" + destinationDirectory = customNeo4jPluginDestinationDir + + inputs.files(sourceSets.customNeo4jPlugin.java.srcDirs) + outputs.cacheIf { true } + outputs.dir(customNeo4jPluginDestinationDir) +} + +processTestResources.dependsOn customNeo4jPluginJar + +dependencies { + customNeo4jPluginCompileOnly "org.neo4j:neo4j:3.5.35" + + api project(":testcontainers") + + testImplementation 'org.neo4j.driver:neo4j-java-driver:4.4.22' +} diff --git a/modules/neo4j/src/custom-neo4j-plugin/java/ac/simons/neo4j/demos/plugins/HelloWorld.java b/modules/neo4j/src/custom-neo4j-plugin/java/ac/simons/neo4j/demos/plugins/HelloWorld.java new file mode 100644 index 00000000000..8948c98d53c --- /dev/null +++ b/modules/neo4j/src/custom-neo4j-plugin/java/ac/simons/neo4j/demos/plugins/HelloWorld.java @@ -0,0 +1,14 @@ +package ac.simons.neo4j.demos.plugins; + +import org.neo4j.procedure.Description; +import org.neo4j.procedure.Name; +import org.neo4j.procedure.UserFunction; + +public class HelloWorld { + + @UserFunction("ac.simons.helloWorld") + @Description("Simple Hello World") + public String helloWorld(@Name("name") String name) { + return "Hello, " + name; + } +} diff --git a/modules/neo4j/src/main/java/org/testcontainers/containers/Neo4jContainer.java b/modules/neo4j/src/main/java/org/testcontainers/containers/Neo4jContainer.java new file mode 100644 index 00000000000..f7fc0beacfe --- /dev/null +++ b/modules/neo4j/src/main/java/org/testcontainers/containers/Neo4jContainer.java @@ -0,0 +1,369 @@ +package org.testcontainers.containers; + +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.LicenseAcceptance; +import org.testcontainers.utility.MountableFile; + +import java.net.HttpURLConnection; +import java.time.Duration; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Testcontainers implementation for Neo4j. + *

+ * Supported image: {@code neo4j} + *

+ * Exposed ports: + *

    + *
  • Bolt: 7687
  • + *
  • HTTP: 7474
  • + *
  • HTTPS: 7473
  • + *
+ * + * @deprecated use {@link org.testcontainers.neo4j.Neo4jContainer} instead. + */ +@Deprecated +public class Neo4jContainer> extends GenericContainer { + + /** + * The image defaults to the official Neo4j image: Neo4j. + */ + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("neo4j"); + + /** + * The default tag (version) to use. + */ + private static final String DEFAULT_TAG = "4.4"; + + private static final String ENTERPRISE_TAG = DEFAULT_TAG + "-enterprise"; + + /** + * Default port for the binary Bolt protocol. + */ + private static final int DEFAULT_BOLT_PORT = 7687; + + /** + * The port of the transactional HTTPS endpoint: Neo4j REST API. + */ + private static final int DEFAULT_HTTPS_PORT = 7473; + + /** + * The port of the transactional HTTP endpoint: Neo4j REST API. + */ + private static final int DEFAULT_HTTP_PORT = 7474; + + /** + * The official image requires a change of password by default from "neo4j" to something else. This defaults to "password". + */ + private static final String DEFAULT_ADMIN_PASSWORD = "password"; + + private static final String AUTH_FORMAT = "neo4j/%s"; + + private final boolean standardImage; + + private String adminPassword = DEFAULT_ADMIN_PASSWORD; + + private final Set labsPlugins = new HashSet<>(); + + /** + * Default wait strategies + */ + public static final WaitStrategy WAIT_FOR_BOLT = new LogMessageWaitStrategy() + .withRegEx(String.format(".*Bolt enabled on .*:%d\\.\n", DEFAULT_BOLT_PORT)); + + private static final WaitStrategy WAIT_FOR_HTTP = new HttpWaitStrategy() + .forPort(DEFAULT_HTTP_PORT) + .forStatusCodeMatching(response -> response == HttpURLConnection.HTTP_OK); + + /** + * Creates a Neo4jContainer using the official Neo4j docker image. + * @deprecated use {@link #Neo4jContainer(DockerImageName)} instead + */ + @Deprecated + public Neo4jContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + /** + * Creates a Neo4jContainer using a specific docker image. + * + * @param dockerImageName The docker image to use. + */ + public Neo4jContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + /** + * Creates a Neo4jContainer using a specific docker image. + * + * @param dockerImageName The docker image to use. + */ + public Neo4jContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + this.standardImage = dockerImageName.getUnversionedPart().equals(DEFAULT_IMAGE_NAME.getUnversionedPart()); + + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + this.waitStrategy = + new WaitAllStrategy() + .withStrategy(WAIT_FOR_BOLT) + .withStrategy(WAIT_FOR_HTTP) + .withStartupTimeout(Duration.ofMinutes(2)); + + addExposedPorts(DEFAULT_BOLT_PORT, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT); + } + + @Override + public Set getLivenessCheckPortNumbers() { + return Stream + .of(DEFAULT_BOLT_PORT, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT) + .map(this::getMappedPort) + .collect(Collectors.toSet()); + } + + @Override + protected void configure() { + configureAuth(); + configureLabsPlugins(); + configureWaitStrategy(); + } + + /** + * Configured via {@link Neo4jContainer#withAdminPassword(String)} or {@link Neo4jContainer#withoutAuthentication()} + * It is only possible to set the correct auth in the configuration call. + * Also, the custom methods overrule the set env parameter. + */ + private void configureAuth() { + String neo4jAuthEnvKey = "NEO4J_AUTH"; + if (!getEnvMap().containsKey(neo4jAuthEnvKey) || !DEFAULT_ADMIN_PASSWORD.equals(this.adminPassword)) { + boolean emptyAdminPassword = this.adminPassword == null || this.adminPassword.isEmpty(); + String neo4jAuth = emptyAdminPassword ? "none" : String.format(AUTH_FORMAT, this.adminPassword); + addEnv(neo4jAuthEnvKey, neo4jAuth); + } + } + + /** + * Configured via {@link Neo4jContainer#withLabsPlugins}. + * Configuration can only happen in the configuration call because there is no default. + */ + private void configureLabsPlugins() { + String neo4jLabsPluginsEnvKey = "NEO4JLABS_PLUGINS"; + if (!getEnv().contains(neo4jLabsPluginsEnvKey) && !this.labsPlugins.isEmpty()) { + String enabledPlugins = + this.labsPlugins.stream().map(pluginName -> "\"" + pluginName + "\"").collect(Collectors.joining(",")); + + addEnv(neo4jLabsPluginsEnvKey, "[" + enabledPlugins + "]"); + } + } + + /** + * Update the default Neo4jContainer wait strategy based on the exposed ports. + * Still possible to override the startup timeout before starting the container via {@link WaitStrategy#withStartupTimeout(Duration)}. + */ + private void configureWaitStrategy() { + List exposedPorts = getExposedPorts(); + boolean boltExposed = exposedPorts.contains(DEFAULT_BOLT_PORT); + boolean httpExposed = exposedPorts.contains(DEFAULT_HTTP_PORT); + boolean onlyBoltExposed = boltExposed && !httpExposed; + boolean onlyHttpExposed = !boltExposed && httpExposed; + + if (onlyBoltExposed) { + this.waitStrategy = + new WaitAllStrategy().withStrategy(WAIT_FOR_BOLT).withStartupTimeout(Duration.ofMinutes(2)); + } else if (onlyHttpExposed) { + this.waitStrategy = + new WaitAllStrategy().withStrategy(WAIT_FOR_HTTP).withStartupTimeout(Duration.ofMinutes(2)); + } + } + + /** + * @return Bolt URL for use with Neo4j's Java-Driver. + */ + public String getBoltUrl() { + return String.format("bolt://" + getHost() + ":" + getMappedPort(DEFAULT_BOLT_PORT)); + } + + /** + * @return URL of the transactional HTTP endpoint. + */ + public String getHttpUrl() { + return String.format("http://" + getHost() + ":" + getMappedPort(DEFAULT_HTTP_PORT)); + } + + /** + * @return URL of the transactional HTTPS endpoint. + */ + public String getHttpsUrl() { + return String.format("https://" + getHost() + ":" + getMappedPort(DEFAULT_HTTPS_PORT)); + } + + /** + * Configures the container to use the enterprise edition of the default docker image. + *

+ * Please have a look at the Neo4j Licensing page. While the Neo4j + * Community Edition can be used for free in your projects under the GPL v3 license, Neo4j Enterprise edition + * needs either a commercial, education or evaluation license. + * + * @return This container. + */ + public S withEnterpriseEdition() { + if (!standardImage) { + throw new IllegalStateException( + String.format("Cannot use enterprise version with alternative image %s.", getDockerImageName()) + ); + } + + setDockerImageName(DEFAULT_IMAGE_NAME.withTag(ENTERPRISE_TAG).asCanonicalNameString()); + LicenseAcceptance.assertLicenseAccepted(getDockerImageName()); + + addEnv("NEO4J_ACCEPT_LICENSE_AGREEMENT", "yes"); + + return self(); + } + + /** + * Sets the admin password for the default account (which is
neo4j
). A null value or an empty string + * disables authentication. + * + * @param adminPassword The admin password for the default database account. + * @return This container. + */ + public S withAdminPassword(final String adminPassword) { + if (adminPassword != null && adminPassword.length() < 8) { + logger().warn("Your provided admin password is too short and will not work with Neo4j 5.3+."); + } + this.adminPassword = adminPassword; + return self(); + } + + /** + * Disables authentication. + * + * @return This container. + */ + public S withoutAuthentication() { + return withAdminPassword(null); + } + + /** + * Copies an existing {@code graph.db} folder into the container. This can either be a classpath resource or a + * host resource. Please have a look at the factory methods in {@link MountableFile}. + *
+ * If you want to map your database into the container instead of copying them, please use {@code #withClasspathResourceMapping}, + * but this will only work when your test does not run in a container itself. + *
+ * Note: This method only works with Neo4j 3.5. + *
+ * Mapping would work like this: + *
+     *      @Container
+     *      private static final Neo4jContainer databaseServer = new Neo4jContainer<>()
+     *          .withClasspathResourceMapping("/test-graph.db", "/data/databases/graph.db", BindMode.READ_WRITE);
+     * 
+ * + * @param graphDb The graph.db folder to copy into the container + * @throws IllegalArgumentException If the database version is not 3.5. + * @return This container. + */ + public S withDatabase(MountableFile graphDb) { + if (!isNeo4jDatabaseVersionSupportingDbCopy()) { + throw new IllegalArgumentException( + "Copying database folder is not supported for Neo4j instances with version 4.0 or higher." + ); + } + return withCopyFileToContainer(graphDb, "/data/databases/graph.db"); + } + + /** + * Adds plugins to the given directory to the container. If {@code plugins} denotes a directory, than all of that + * directory is mapped to Neo4j's plugins. Otherwise, single resources are copied over. + *
+ * If you want to map your plugins into the container instead of copying them, please use {@code #withClasspathResourceMapping}, + * but this will only work when your test does not run in a container itself. + * + * @param plugins + * @return This container. + */ + public S withPlugins(MountableFile plugins) { + return withCopyFileToContainer(plugins, "/var/lib/neo4j/plugins/"); + } + + /** + * Adds Neo4j configuration properties to the container. The properties can be added as in the official Neo4j + * configuration, the method automatically translate them into the format required by the Neo4j container. + * + * @param key The key to configure, i.e. {@code dbms.security.procedures.unrestricted} + * @param value The value to set + * @return This container. + */ + public S withNeo4jConfig(String key, String value) { + addEnv(formatConfigurationKey(key), value); + return self(); + } + + /** + * @return The admin password for the neo4j account or literal null if auth is disabled. + */ + public String getAdminPassword() { + return adminPassword; + } + + /** + * Registers one or more Neo4j plugins for server startup. + * The plugins are listed here + * + * + * @param plugins The Neo4j plugins that should get started with the server. + * @return This container. + */ + public S withPlugins(String... plugins) { + this.labsPlugins.addAll(Arrays.asList(plugins)); + return self(); + } + + private static String formatConfigurationKey(String plainConfigKey) { + final String prefix = "NEO4J_"; + + return String.format("%s%s", prefix, plainConfigKey.replaceAll("_", "__").replaceAll("\\.", "_")); + } + + private boolean isNeo4jDatabaseVersionSupportingDbCopy() { + String usedImageVersion = DockerImageName.parse(getDockerImageName()).getVersionPart(); + ComparableVersion usedComparableVersion = new ComparableVersion(usedImageVersion); + + boolean versionSupportingDbCopy = + usedComparableVersion.isLessThan("4.0") && usedComparableVersion.isGreaterThanOrEqualTo("2"); + + if (versionSupportingDbCopy) { + return true; + } + if (!usedComparableVersion.isSemanticVersion()) { + logger() + .warn( + "Version {} is not a semantic version. The function \"withDatabase\" will fail.", + usedImageVersion + ); + logger().warn("Copying databases is only supported for Neo4j versions 3.5.x"); + } + + return false; + } + + public S withRandomPassword() { + return withAdminPassword(UUID.randomUUID().toString()); + } +} diff --git a/modules/neo4j/src/main/java/org/testcontainers/neo4j/Neo4jContainer.java b/modules/neo4j/src/main/java/org/testcontainers/neo4j/Neo4jContainer.java new file mode 100644 index 00000000000..0349e0c2395 --- /dev/null +++ b/modules/neo4j/src/main/java/org/testcontainers/neo4j/Neo4jContainer.java @@ -0,0 +1,331 @@ +package org.testcontainers.neo4j; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.net.HttpURLConnection; +import java.time.Duration; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Testcontainers implementation for Neo4j. + *

+ * Supported image: {@code neo4j} + *

+ * Exposed ports: + *

    + *
  • Bolt: 7687
  • + *
  • HTTP: 7474
  • + *
  • HTTPS: 7473
  • + *
+ */ +public class Neo4jContainer extends GenericContainer { + + /** + * The image defaults to the official Neo4j image: Neo4j. + */ + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("neo4j"); + + /** + * Default port for the binary Bolt protocol. + */ + private static final int DEFAULT_BOLT_PORT = 7687; + + /** + * The port of the transactional HTTPS endpoint: Neo4j REST API. + */ + private static final int DEFAULT_HTTPS_PORT = 7473; + + /** + * The port of the transactional HTTP endpoint: Neo4j REST API. + */ + private static final int DEFAULT_HTTP_PORT = 7474; + + /** + * The official image requires a change of password by default from "neo4j" to something else. This defaults to "password". + */ + private static final String DEFAULT_ADMIN_PASSWORD = "password"; + + private static final String AUTH_FORMAT = "neo4j/%s"; + + private String adminPassword = DEFAULT_ADMIN_PASSWORD; + + private final Set labsPlugins = new HashSet<>(); + + /** + * Default wait strategies + */ + public static final WaitStrategy WAIT_FOR_BOLT = new LogMessageWaitStrategy() + .withRegEx(String.format(".*Bolt enabled on .*:%d\\.\n", DEFAULT_BOLT_PORT)); + + private static final WaitStrategy WAIT_FOR_HTTP = new HttpWaitStrategy() + .forPort(DEFAULT_HTTP_PORT) + .forStatusCodeMatching(response -> response == HttpURLConnection.HTTP_OK); + + /** + * Creates a Neo4jContainer using a specific docker image. + * + * @param dockerImageName The docker image to use. + */ + public Neo4jContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + /** + * Creates a Neo4jContainer using a specific docker image. + * + * @param dockerImageName The docker image to use. + */ + public Neo4jContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + waitingFor( + new WaitAllStrategy() + .withStrategy(WAIT_FOR_BOLT) + .withStrategy(WAIT_FOR_HTTP) + .withStartupTimeout(Duration.ofMinutes(2)) + ); + + addExposedPorts(DEFAULT_BOLT_PORT, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT); + } + + @Override + public Set getLivenessCheckPortNumbers() { + return Stream + .of(DEFAULT_BOLT_PORT, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT) + .map(this::getMappedPort) + .collect(Collectors.toSet()); + } + + @Override + protected void configure() { + configureAuth(); + configureLabsPlugins(); + configureWaitStrategy(); + } + + /** + * Configured via {@link Neo4jContainer#withAdminPassword(String)} or {@link Neo4jContainer#withoutAuthentication()} + * It is only possible to set the correct auth in the configuration call. + * Also, the custom methods overrule the set env parameter. + */ + private void configureAuth() { + String neo4jAuthEnvKey = "NEO4J_AUTH"; + if (!getEnvMap().containsKey(neo4jAuthEnvKey) || !DEFAULT_ADMIN_PASSWORD.equals(this.adminPassword)) { + boolean emptyAdminPassword = this.adminPassword == null || this.adminPassword.isEmpty(); + String neo4jAuth = emptyAdminPassword ? "none" : String.format(AUTH_FORMAT, this.adminPassword); + addEnv(neo4jAuthEnvKey, neo4jAuth); + } + } + + /** + * Configured via {@link Neo4jContainer#withLabsPlugins}. + * Configuration can only happen in the configuration call because there is no default. + */ + private void configureLabsPlugins() { + String neo4jLabsPluginsEnvKey = "NEO4JLABS_PLUGINS"; + if (!getEnv().contains(neo4jLabsPluginsEnvKey) && !this.labsPlugins.isEmpty()) { + String enabledPlugins = + this.labsPlugins.stream().map(pluginName -> "\"" + pluginName + "\"").collect(Collectors.joining(",")); + + addEnv(neo4jLabsPluginsEnvKey, "[" + enabledPlugins + "]"); + } + } + + /** + * Update the default Neo4jContainer wait strategy based on the exposed ports. + * Still possible to override the startup timeout before starting the container via {@link WaitStrategy#withStartupTimeout(Duration)}. + */ + private void configureWaitStrategy() { + List exposedPorts = getExposedPorts(); + boolean boltExposed = exposedPorts.contains(DEFAULT_BOLT_PORT); + boolean httpExposed = exposedPorts.contains(DEFAULT_HTTP_PORT); + boolean onlyBoltExposed = boltExposed && !httpExposed; + boolean onlyHttpExposed = !boltExposed && httpExposed; + + if (onlyBoltExposed) { + waitingFor(new WaitAllStrategy().withStrategy(WAIT_FOR_BOLT).withStartupTimeout(Duration.ofMinutes(2))); + } else if (onlyHttpExposed) { + waitingFor(new WaitAllStrategy().withStrategy(WAIT_FOR_HTTP).withStartupTimeout(Duration.ofMinutes(2))); + } + } + + /** + * @return Bolt URL for use with Neo4j's Java-Driver. + */ + public String getBoltUrl() { + return String.format("bolt://" + getHost() + ":" + getMappedPort(DEFAULT_BOLT_PORT)); + } + + /** + * @return URL of the transactional HTTP endpoint. + */ + public String getHttpUrl() { + return String.format("http://" + getHost() + ":" + getMappedPort(DEFAULT_HTTP_PORT)); + } + + /** + * @return URL of the transactional HTTPS endpoint. + */ + public String getHttpsUrl() { + return String.format("https://" + getHost() + ":" + getMappedPort(DEFAULT_HTTPS_PORT)); + } + + /** + * Accepts the license agreement of the container. + * + * @return this + */ + public Neo4jContainer acceptLicense() { + addEnv("NEO4J_ACCEPT_LICENSE_AGREEMENT", "yes"); + return self(); + } + + /** + * Sets the admin password for the default account (which is
neo4j
). A null value or an empty string + * disables authentication. + * + * @param adminPassword The admin password for the default database account. + * @return This container. + */ + public Neo4jContainer withAdminPassword(final String adminPassword) { + if (adminPassword != null && adminPassword.length() < 8) { + logger().warn("Your provided admin password is too short and will not work with Neo4j 5.3+."); + } + this.adminPassword = adminPassword; + return self(); + } + + /** + * Disables authentication. + * + * @return This container. + */ + public Neo4jContainer withoutAuthentication() { + return withAdminPassword(null); + } + + /** + * Copies an existing {@code graph.db} folder into the container. This can either be a classpath resource or a + * host resource. Please have a look at the factory methods in {@link MountableFile}. + *
+ * If you want to map your database into the container instead of copying them, please use {@code #withClasspathResourceMapping}, + * but this will only work when your test does not run in a container itself. + *
+ * Note: This method only works with Neo4j 3.5. + *
+ * Mapping would work like this: + *
+     *      @Container
+     *      private static final Neo4jContainer databaseServer = new Neo4jContainer<>()
+     *          .withClasspathResourceMapping("/test-graph.db", "/data/databases/graph.db", BindMode.READ_WRITE);
+     * 
+ * + * @param graphDb The graph.db folder to copy into the container + * @throws IllegalArgumentException If the database version is not 3.5. + * @return This container. + */ + public Neo4jContainer withDatabase(MountableFile graphDb) { + if (!isNeo4jDatabaseVersionSupportingDbCopy()) { + throw new IllegalArgumentException( + "Copying database folder is not supported for Neo4j instances with version 4.0 or higher." + ); + } + return withCopyFileToContainer(graphDb, "/data/databases/graph.db"); + } + + /** + * Adds plugins to the given directory to the container. If {@code plugins} denotes a directory, than all of that + * directory is mapped to Neo4j's plugins. Otherwise, single resources are copied over. + *
+ * If you want to map your plugins into the container instead of copying them, please use {@code #withClasspathResourceMapping}, + * but this will only work when your test does not run in a container itself. + * + * @param plugins + * @return This container. + */ + public Neo4jContainer withPlugins(MountableFile plugins) { + return withCopyFileToContainer(plugins, "/var/lib/neo4j/plugins/"); + } + + /** + * Adds Neo4j configuration properties to the container. The properties can be added as in the official Neo4j + * configuration, the method automatically translate them into the format required by the Neo4j container. + * + * @param key The key to configure, i.e. {@code dbms.security.procedures.unrestricted} + * @param value The value to set + * @return This container. + */ + public Neo4jContainer withNeo4jConfig(String key, String value) { + addEnv(formatConfigurationKey(key), value); + return self(); + } + + /** + * @return The admin password for the neo4j account or literal null if auth is disabled. + */ + public String getAdminPassword() { + return adminPassword; + } + + /** + * Registers one or more Neo4j plugins for server startup. + * The plugins are listed here + * + * + * @param plugins The Neo4j plugins that should get started with the server. + * @return This container. + */ + public Neo4jContainer withPlugins(String... plugins) { + this.labsPlugins.addAll(Arrays.asList(plugins)); + return self(); + } + + private static String formatConfigurationKey(String plainConfigKey) { + final String prefix = "NEO4J_"; + + return String.format("%s%s", prefix, plainConfigKey.replaceAll("_", "__").replaceAll("\\.", "_")); + } + + private boolean isNeo4jDatabaseVersionSupportingDbCopy() { + String usedImageVersion = DockerImageName.parse(getDockerImageName()).getVersionPart(); + ComparableVersion usedComparableVersion = new ComparableVersion(usedImageVersion); + + boolean versionSupportingDbCopy = + usedComparableVersion.isLessThan("4.0") && usedComparableVersion.isGreaterThanOrEqualTo("2"); + + if (versionSupportingDbCopy) { + return true; + } + if (!usedComparableVersion.isSemanticVersion()) { + logger() + .warn( + "Version {} is not a semantic version. The function \"withDatabase\" will fail.", + usedImageVersion + ); + logger().warn("Copying databases is only supported for Neo4j versions 3.5.x"); + } + + return false; + } + + public Neo4jContainer withRandomPassword() { + return withAdminPassword(UUID.randomUUID().toString()); + } +} diff --git a/modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java b/modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java new file mode 100644 index 00000000000..b880e7b4e8f --- /dev/null +++ b/modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java @@ -0,0 +1,361 @@ +package org.testcontainers.neo4j; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.AppenderBase; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.AuthToken; +import org.neo4j.driver.AuthTokens; +import org.neo4j.driver.Driver; +import org.neo4j.driver.GraphDatabase; +import org.neo4j.driver.Record; +import org.neo4j.driver.Result; +import org.neo4j.driver.Session; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; +import org.testcontainers.utility.DockerLoggerFactory; +import org.testcontainers.utility.MountableFile; + +import java.util.Collections; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assumptions.assumeThat; + +class Neo4jContainerTest { + + // See org.testcontainers.utility.LicenseAcceptance#ACCEPTANCE_FILE_NAME + private static final String ACCEPTANCE_FILE_LOCATION = "/container-license-acceptance.txt"; + + @Test + void authenticated() { + try ( + // container { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4") + // } + ) { + neo4j.start(); + try (Driver driver = getDriver(neo4j); Session session = driver.session()) { + long one = session.run("RETURN 1", Collections.emptyMap()).next().get(0).asLong(); + assertThat(one).isEqualTo(1L); + } + } + } + + @Test + void shouldDisableAuthentication() { + try ( + // spotless:off + // withoutAuthentication { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4") + .withoutAuthentication() + // } + // spotless:on + ) { + neo4j.start(); + try (Driver driver = getDriver(neo4j); Session session = driver.session()) { + long one = session.run("RETURN 1", Collections.emptyMap()).next().get(0).asLong(); + assertThat(one).isEqualTo(1L); + } + } + } + + @Test + void shouldCopyDatabase() { + // no aarch64 image available for Neo4j 3.5 + assumeThat(DockerClientFactory.instance().getInfo().getArchitecture()).isNotEqualTo("aarch64"); + try ( + // copyDatabase { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:3.5.30") + .withDatabase(MountableFile.forClasspathResource("/test-graph.db")) + // } + ) { + neo4j.start(); + try (Driver driver = getDriver(neo4j); Session session = driver.session()) { + Result result = session.run("MATCH (t:Thing) RETURN t"); + assertThat(result.list().stream().map(r -> r.get("t").get("name").asString())) + .containsExactlyInAnyOrder("Thing", "Thing 2", "Thing 3", "A box"); + } + } + } + + @Test + void shouldFailOnCopyDatabaseForDefaultNeo4j4Image() { + assertThatIllegalArgumentException() + .isThrownBy(() -> { + new Neo4jContainer("neo4j:4.4.1").withDatabase(MountableFile.forClasspathResource("/test-graph.db")); + }) + .withMessage("Copying database folder is not supported for Neo4j instances with version 4.0 or higher."); + } + + @Test + void shouldFailOnCopyDatabaseForCustomNeo4j4Image() { + assertThatIllegalArgumentException() + .isThrownBy(() -> { + new Neo4jContainer("neo4j:4.4.1").withDatabase(MountableFile.forClasspathResource("/test-graph.db")); + }) + .withMessage("Copying database folder is not supported for Neo4j instances with version 4.0 or higher."); + } + + @Test + void shouldFailOnCopyDatabaseForCustomNonSemverNeo4j4Image() { + assertThatIllegalArgumentException() + .isThrownBy(() -> { + new Neo4jContainer("neo4j:latest").withDatabase(MountableFile.forClasspathResource("/test-graph.db")); + }) + .withMessage("Copying database folder is not supported for Neo4j instances with version 4.0 or higher."); + } + + @Test + void shouldCopyPlugins() { + try ( + // registerPluginsPath { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4") + .withPlugins(MountableFile.forClasspathResource("/custom-plugins")) + // } + ) { + neo4j.start(); + try (Driver driver = getDriver(neo4j); Session session = driver.session()) { + assertThatCustomPluginWasCopied(session); + } + } + } + + @Test + void shouldCopyPlugin() { + try ( + // registerPluginsJar { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4") + .withPlugins(MountableFile.forClasspathResource("/custom-plugins/hello-world.jar")) + // } + ) { + neo4j.start(); + try (Driver driver = getDriver(neo4j); Session session = driver.session()) { + assertThatCustomPluginWasCopied(session); + } + } + } + + private static void assertThatCustomPluginWasCopied(Session session) { + Result result = session.run("RETURN ac.simons.helloWorld('Testcontainers') AS greeting"); + Record singleRecord = result.single(); + assertThat(singleRecord).isNotNull(); + assertThat(singleRecord.get("greeting").asString()).isEqualTo("Hello, Testcontainers"); + } + + @Test + void shouldRunEnterprise() { + assumeThat(Neo4jContainerTest.class.getResource(ACCEPTANCE_FILE_LOCATION)).isNotNull(); + + try ( + // enterpriseEdition { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4-enterprise") + .acceptLicense() + // } + .withAdminPassword("Picard123") + ) { + neo4j.start(); + try (Driver driver = getDriver(neo4j); Session session = driver.session()) { + String edition = session + .run("CALL dbms.components() YIELD edition RETURN edition", Collections.emptyMap()) + .next() + .get(0) + .asString(); + assertThat(edition).isEqualTo("enterprise"); + } + } + } + + @Test + void shouldAddConfigToEnvironment() { + // neo4jConfiguration { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4") + .withNeo4jConfig("dbms.security.procedures.unrestricted", "apoc.*,algo.*") + .withNeo4jConfig("dbms.tx_log.rotation.size", "42M"); + // } + + assertThat(neo4j.getEnvMap()).containsEntry("NEO4J_dbms_security_procedures_unrestricted", "apoc.*,algo.*"); + assertThat(neo4j.getEnvMap()).containsEntry("NEO4J_dbms_tx__log_rotation_size", "42M"); + } + + @Test + void shouldRespectEnvironmentAuth() { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4").withEnv("NEO4J_AUTH", "neo4j/secret"); + + neo4j.configure(); + + assertThat(neo4j.getEnvMap()).containsEntry("NEO4J_AUTH", "neo4j/secret"); + } + + @Test + void shouldSetCustomPasswordCorrectly() { + // withAdminPassword { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4").withAdminPassword("verySecret"); + // } + + neo4j.configure(); + + assertThat(neo4j.getEnvMap()).containsEntry("NEO4J_AUTH", "neo4j/verySecret"); + } + + @Test + void adminPasswordOverrulesEnvironmentAuth() { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4") + .withEnv("NEO4J_AUTH", "neo4j/secret") + .withAdminPassword("anotherSecret"); + + neo4j.configure(); + + assertThat(neo4j.getEnvMap()).containsEntry("NEO4J_AUTH", "neo4j/anotherSecret"); + } + + @Test + void shouldWithoutAuthenticationOverrulesEnvironmentAuth() { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4") + .withEnv("NEO4J_AUTH", "neo4j/secret") + .withoutAuthentication(); + + neo4j.configure(); + + assertThat(neo4j.getEnvMap()).containsEntry("NEO4J_AUTH", "none"); + } + + @Test + void shouldRespectAlreadyDefinedPortMappingsBolt() { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4").withExposedPorts(7687); + + neo4j.configure(); + + assertThat(neo4j.getExposedPorts()).containsExactly(7687); + } + + @Test + void shouldRespectAlreadyDefinedPortMappingsHttp() { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4").withExposedPorts(7474); + + neo4j.configure(); + + assertThat(neo4j.getExposedPorts()).containsExactly(7474); + } + + @Test + void shouldRespectAlreadyDefinedPortMappingsWithoutHttps() { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4").withExposedPorts(7687, 7474); + + neo4j.configure(); + + assertThat(neo4j.getExposedPorts()).containsExactlyInAnyOrder(7474, 7687); + } + + @Test + void shouldDefaultExportBoltHttpAndHttps() { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4"); + + neo4j.configure(); + + assertThat(neo4j.getExposedPorts()).containsExactlyInAnyOrder(7473, 7474, 7687); + } + + @Test + void shouldRespectCustomWaitStrategy() { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4").waitingFor(new CustomDummyWaitStrategy()); + + neo4j.configure(); + + assertThat(neo4j).extracting("waitStrategy").isInstanceOf(CustomDummyWaitStrategy.class); + } + + @Test + void shouldConfigureSinglePluginByName() { + try (Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4").withPlugins("apoc")) { + // needs to get called explicitly for setup + neo4j.configure(); + + assertThat(neo4j.getEnvMap()).containsEntry("NEO4JLABS_PLUGINS", "[\"apoc\"]"); + } + } + + @Test + void shouldConfigureMultiplePluginsByName() { + try ( + // configureLabsPlugins { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4") // + .withPlugins("apoc", "bloom"); + // } + ) { + // needs to get called explicitly for setup + neo4j.configure(); + + assertThat(neo4j.getEnvMap().get("NEO4JLABS_PLUGINS")) + .containsAnyOf("[\"apoc\",\"bloom\"]", "[\"bloom\",\"apoc\"]"); + } + } + + @Test + void shouldCreateRandomUuidBasedPasswords() { + try ( + // withRandomPassword { + Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4").withRandomPassword(); + // } + ) { + // It will throw an exception if it's not UUID parsable. + assertThatNoException().isThrownBy(neo4j::configure); + // This basically is always true at if the random password is UUID-like. + assertThat(neo4j.getAdminPassword()) + .satisfies(password -> assertThat(UUID.fromString(password).toString()).isEqualTo(password)); + } + } + + @Test + void shouldWarnOnPasswordTooShort() { + try (Neo4jContainer neo4j = new Neo4jContainer("neo4j:4.4");) { + Logger logger = (Logger) DockerLoggerFactory.getLogger("neo4j:4.4"); + TestLogAppender testLogAppender = new TestLogAppender(); + logger.addAppender(testLogAppender); + testLogAppender.start(); + + neo4j.withAdminPassword("short"); + + testLogAppender.stop(); + + assertThat(testLogAppender.passwordTooShortWarningAppeared).isTrue(); + } + } + + private static class CustomDummyWaitStrategy extends AbstractWaitStrategy { + + @Override + protected void waitUntilReady() { + // ehm...ready + } + } + + private static class TestLogAppender extends AppenderBase { + + boolean passwordTooShortWarningAppeared = false; + + @Override + protected void append(ILoggingEvent eventObject) { + if (eventObject.getLevel().equals(Level.WARN)) { + if ( + eventObject + .getMessage() + .equals("Your provided admin password is too short and will not work with Neo4j 5.3+.") + ) { + passwordTooShortWarningAppeared = true; + } + } + } + } + + private static Driver getDriver(Neo4jContainer neo4j) { + AuthToken authToken = AuthTokens.none(); + if (neo4j.getAdminPassword() != null) { + authToken = AuthTokens.basic("neo4j", neo4j.getAdminPassword()); + } + return GraphDatabase.driver(neo4j.getBoltUrl(), authToken); + } +} diff --git a/modules/neo4j/src/test/resources/example-container-license-acceptance.txt b/modules/neo4j/src/test/resources/example-container-license-acceptance.txt new file mode 100644 index 00000000000..e1f78f2d982 --- /dev/null +++ b/modules/neo4j/src/test/resources/example-container-license-acceptance.txt @@ -0,0 +1 @@ +neo4j:4.4.1-enterprise diff --git a/modules/neo4j/src/test/resources/logback-test.xml b/modules/neo4j/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/neo4j/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore b/modules/neo4j/src/test/resources/test-graph.db/neostore new file mode 100644 index 00000000000..cbd1bec5bd7 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.counts.db.a b/modules/neo4j/src/test/resources/test-graph.db/neostore.counts.db.a new file mode 100644 index 00000000000..42f972e9874 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.counts.db.a differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.counts.db.b b/modules/neo4j/src/test/resources/test-graph.db/neostore.counts.db.b new file mode 100644 index 00000000000..92ac40b0b5c Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.counts.db.b differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.id b/modules/neo4j/src/test/resources/test-graph.db/neostore.id new file mode 100644 index 00000000000..a31ef50d03e Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.id differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.labelscanstore.db b/modules/neo4j/src/test/resources/test-graph.db/neostore.labelscanstore.db new file mode 100644 index 00000000000..a07a920caef Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.labelscanstore.db differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.labeltokenstore.db b/modules/neo4j/src/test/resources/test-graph.db/neostore.labeltokenstore.db new file mode 100644 index 00000000000..c4888e37cb5 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.labeltokenstore.db differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.labeltokenstore.db.id b/modules/neo4j/src/test/resources/test-graph.db/neostore.labeltokenstore.db.id new file mode 100644 index 00000000000..102ef245553 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.labeltokenstore.db.id differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.labeltokenstore.db.names b/modules/neo4j/src/test/resources/test-graph.db/neostore.labeltokenstore.db.names new file mode 100644 index 00000000000..83ad4c51fc5 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.labeltokenstore.db.names differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.labeltokenstore.db.names.id b/modules/neo4j/src/test/resources/test-graph.db/neostore.labeltokenstore.db.names.id new file mode 100644 index 00000000000..bdf13bc6a20 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.labeltokenstore.db.names.id differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.nodestore.db b/modules/neo4j/src/test/resources/test-graph.db/neostore.nodestore.db new file mode 100644 index 00000000000..901a0510058 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.nodestore.db differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.nodestore.db.id b/modules/neo4j/src/test/resources/test-graph.db/neostore.nodestore.db.id new file mode 100644 index 00000000000..9a0b5a4425b Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.nodestore.db.id differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.nodestore.db.labels b/modules/neo4j/src/test/resources/test-graph.db/neostore.nodestore.db.labels new file mode 100644 index 00000000000..df8ce2a58c7 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.nodestore.db.labels differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.nodestore.db.labels.id b/modules/neo4j/src/test/resources/test-graph.db/neostore.nodestore.db.labels.id new file mode 100644 index 00000000000..102ef245553 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.nodestore.db.labels.id differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db new file mode 100644 index 00000000000..a2583744d0f Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.arrays b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.arrays new file mode 100644 index 00000000000..5595acd18d1 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.arrays differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.arrays.id b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.arrays.id new file mode 100644 index 00000000000..ac6a93396df Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.arrays.id differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.id b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.id new file mode 100644 index 00000000000..9a0b5a4425b Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.id differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.index b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.index new file mode 100644 index 00000000000..33a49bb83c6 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.index differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.index.id b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.index.id new file mode 100644 index 00000000000..bdf13bc6a20 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.index.id differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.index.keys b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.index.keys new file mode 100644 index 00000000000..34c25065f4c Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.index.keys differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.index.keys.id b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.index.keys.id new file mode 100644 index 00000000000..df3610a3fa6 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.index.keys.id differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.strings b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.strings new file mode 100644 index 00000000000..d19598d74e5 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.strings differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.strings.id b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.strings.id new file mode 100644 index 00000000000..102ef245553 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.propertystore.db.strings.id differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshipgroupstore.db b/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshipgroupstore.db new file mode 100644 index 00000000000..b72840ef50b Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshipgroupstore.db differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshipgroupstore.db.id b/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshipgroupstore.db.id new file mode 100644 index 00000000000..102ef245553 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshipgroupstore.db.id differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshipstore.db b/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshipstore.db new file mode 100644 index 00000000000..e69de29bb2d diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshipstore.db.id b/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshipstore.db.id new file mode 100644 index 00000000000..bc8840b22b1 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshipstore.db.id differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshiptypestore.db b/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshiptypestore.db new file mode 100644 index 00000000000..e69de29bb2d diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshiptypestore.db.id b/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshiptypestore.db.id new file mode 100644 index 00000000000..bc8840b22b1 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshiptypestore.db.id differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshiptypestore.db.names b/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshiptypestore.db.names new file mode 100644 index 00000000000..ce6529796fe Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshiptypestore.db.names differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshiptypestore.db.names.id b/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshiptypestore.db.names.id new file mode 100644 index 00000000000..102ef245553 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.relationshiptypestore.db.names.id differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.schemastore.db b/modules/neo4j/src/test/resources/test-graph.db/neostore.schemastore.db new file mode 100644 index 00000000000..df8ce2a58c7 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.schemastore.db differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.schemastore.db.id b/modules/neo4j/src/test/resources/test-graph.db/neostore.schemastore.db.id new file mode 100644 index 00000000000..102ef245553 Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.schemastore.db.id differ diff --git a/modules/neo4j/src/test/resources/test-graph.db/neostore.transaction.db.0 b/modules/neo4j/src/test/resources/test-graph.db/neostore.transaction.db.0 new file mode 100644 index 00000000000..c7e1d7a4f9c Binary files /dev/null and b/modules/neo4j/src/test/resources/test-graph.db/neostore.transaction.db.0 differ diff --git a/modules/nginx/build.gradle b/modules/nginx/build.gradle new file mode 100644 index 00000000000..60d24d863d8 --- /dev/null +++ b/modules/nginx/build.gradle @@ -0,0 +1,6 @@ +description = "Testcontainers :: Nginx" + +dependencies { + api project(':testcontainers') + compileOnly 'org.jetbrains:annotations:26.1.0' +} diff --git a/modules/nginx/pom.xml b/modules/nginx/pom.xml deleted file mode 100644 index 7745e049104..00000000000 --- a/modules/nginx/pom.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - 4.0.0 - - - org.testcontainers - testcontainers-parent - 1.2.1-SNAPSHOT - ../../pom.xml - - - nginx - TestContainers :: nginx - - - - ${project.groupId} - testcontainers - ${project.version} - - - - ${project.groupId} - selenium - ${project.version} - test - - - - org.seleniumhq.selenium - selenium-api - 2.45.0 - test - - - org.seleniumhq.selenium - selenium-remote-driver - 2.45.0 - test - - - \ No newline at end of file diff --git a/modules/nginx/src/main/java/org/testcontainers/containers/NginxContainer.java b/modules/nginx/src/main/java/org/testcontainers/containers/NginxContainer.java index 2b0257ca409..c66149d2417 100644 --- a/modules/nginx/src/main/java/org/testcontainers/containers/NginxContainer.java +++ b/modules/nginx/src/main/java/org/testcontainers/containers/NginxContainer.java @@ -1,40 +1,68 @@ package org.testcontainers.containers; +import org.jetbrains.annotations.NotNull; import org.testcontainers.containers.traits.LinkableContainer; +import org.testcontainers.utility.DockerImageName; import java.net.MalformedURLException; import java.net.URL; +import java.util.Set; /** - * @author richardnorth + * @deprecated use {@link org.testcontainers.nginx.NginxContainer} instead. */ -public class NginxContainer> extends GenericContainer implements LinkableContainer { +@Deprecated +public class NginxContainer> + extends GenericContainer + implements LinkableContainer { private static final int NGINX_DEFAULT_PORT = 80; + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("nginx"); + + private static final String DEFAULT_TAG = "1.9.4"; + + /** + * @deprecated use {@link #NginxContainer(DockerImageName)} instead + */ + @Deprecated public NginxContainer() { - super("nginx:1.9.4"); + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); } - @Override - protected Integer getLivenessCheckPort() { - return getMappedPort(80); + public NginxContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); } - @Override - protected void configure() { + public NginxContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + addExposedPort(NGINX_DEFAULT_PORT); setCommand("nginx", "-g", "daemon off;"); } + /** + * @return the ports on which to check if the container is ready + * @deprecated use {@link #getLivenessCheckPortNumbers()} instead + */ + @NotNull + @Override + @Deprecated + protected Set getLivenessCheckPorts() { + return super.getLivenessCheckPorts(); + } + public URL getBaseUrl(String scheme, int port) throws MalformedURLException { - return new URL(scheme + "://" + getContainerIpAddress() + ":" + getMappedPort(port)); + return new URL(scheme + "://" + getHost() + ":" + getMappedPort(port)); } + @Deprecated public void setCustomContent(String htmlContentPath) { addFileSystemBind(htmlContentPath, "/usr/share/nginx/html", BindMode.READ_ONLY); } + @Deprecated public SELF withCustomContent(String htmlContentPath) { this.setCustomContent(htmlContentPath); return self(); diff --git a/modules/nginx/src/main/java/org/testcontainers/nginx/NginxContainer.java b/modules/nginx/src/main/java/org/testcontainers/nginx/NginxContainer.java new file mode 100644 index 00000000000..f6eb70814ac --- /dev/null +++ b/modules/nginx/src/main/java/org/testcontainers/nginx/NginxContainer.java @@ -0,0 +1,34 @@ +package org.testcontainers.nginx; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import java.net.MalformedURLException; +import java.net.URL; + +public class NginxContainer extends GenericContainer { + + private static final int NGINX_DEFAULT_PORT = 80; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("nginx"); + + public NginxContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public NginxContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + addExposedPort(NGINX_DEFAULT_PORT); + setCommand("nginx", "-g", "daemon off;"); + } + + public URL getBaseUrl(String scheme, int port) throws MalformedURLException { + return new URL(scheme + "://" + getHost() + ":" + getMappedPort(port)); + } + + public URL getBaseUrl(String scheme) throws MalformedURLException { + return getBaseUrl(scheme, NGINX_DEFAULT_PORT); + } +} diff --git a/modules/nginx/src/test/java/org/testcontainers/junit/SimpleNginxTest.java b/modules/nginx/src/test/java/org/testcontainers/junit/SimpleNginxTest.java deleted file mode 100644 index 750d88ad066..00000000000 --- a/modules/nginx/src/test/java/org/testcontainers/junit/SimpleNginxTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package org.testcontainers.junit; - -import org.junit.BeforeClass; -import org.junit.Rule; -import org.junit.Test; -import org.openqa.selenium.By; -import org.openqa.selenium.remote.DesiredCapabilities; -import org.openqa.selenium.remote.RemoteWebDriver; -import org.testcontainers.containers.BrowserWebDriverContainer; -import org.testcontainers.containers.NginxContainer; - -import java.io.*; -import java.net.URLConnection; - -import static org.rnorth.visibleassertions.VisibleAssertions.*; - - -/** - * @author richardnorth - */ -public class SimpleNginxTest { - - @Rule - public NginxContainer nginx = new NginxContainer() - .withCustomContent(System.getProperty("user.home") + "/.tmp-test-container"); - - @Rule - public BrowserWebDriverContainer chrome = new BrowserWebDriverContainer() - .withDesiredCapabilities(DesiredCapabilities.chrome()) - .withLinkToContainer(nginx, "nginx"); - - @BeforeClass - public static void setupContent() throws FileNotFoundException { - File contentFolder = new File(System.getProperty("user.home") + "/.tmp-test-container"); - contentFolder.mkdir(); - contentFolder.setReadable(true, false); - contentFolder.setWritable(true, false); - contentFolder.setExecutable(true, false); - - - File indexFile = new File(contentFolder, "index.html"); - indexFile.setReadable(true, false); - indexFile.setWritable(true, false); - indexFile.setExecutable(true, false); - - PrintStream printStream = new PrintStream(new FileOutputStream(indexFile)); - printStream.println("This worked"); - printStream.close(); - } - - @Test - public void testSimple() throws Exception { - - info("Base URL is " + nginx.getBaseUrl("http", 80)); - - URLConnection urlConnection = nginx.getBaseUrl("http", 80).openConnection(); - String line = new BufferedReader(new InputStreamReader(urlConnection.getInputStream())).readLine(); - System.out.println(line); - - assertTrue("Using URLConnection, an HTTP GET from the nginx server returns the index.html from the custom content directory", line.contains("This worked")); - } - - @Test - public void testWebDriverToNginxContainerAccessViaContainerLink() throws Exception { - - info("Base URL is " + nginx.getBaseUrl("http", 80)); - - RemoteWebDriver driver = chrome.getWebDriver(); - - driver.get("http://nginx/"); - - assertEquals("Using selenium, an HTTP GET from the nginx server returns the index.html from the custom content directory", "This worked", driver.findElement(By.tagName("body")).getText()); - } -} diff --git a/modules/nginx/src/test/java/org/testcontainers/nginx/NginxContainerTest.java b/modules/nginx/src/test/java/org/testcontainers/nginx/NginxContainerTest.java new file mode 100644 index 00000000000..ec495a06d1c --- /dev/null +++ b/modules/nginx/src/test/java/org/testcontainers/nginx/NginxContainerTest.java @@ -0,0 +1,77 @@ +package org.testcontainers.nginx; + +import lombok.Cleanup; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.net.URL; +import java.net.URLConnection; + +import static org.assertj.core.api.Assertions.assertThat; + +class NginxContainerTest { + + private static final DockerImageName NGINX_IMAGE = DockerImageName.parse("nginx:1.27.0-alpine3.19-slim"); + + private static String tmpDirectory = System.getProperty("user.home") + "/.tmp-test-container"; + + @SuppressWarnings({ "Duplicates", "ResultOfMethodCallIgnored" }) + @BeforeAll + static void setupContent() throws Exception { + // addCustomContent { + // Create a temporary dir + File contentFolder = new File(tmpDirectory); + contentFolder.mkdir(); + contentFolder.deleteOnExit(); + + // And "hello world" HTTP file + File indexFile = new File(contentFolder, "index.html"); + indexFile.deleteOnExit(); + @Cleanup + PrintStream printStream = new PrintStream(new FileOutputStream(indexFile)); + printStream.println("Hello World!"); + // } + } + + @Test + void testSimple() throws Exception { + try ( + // creatingContainer { + NginxContainer nginx = new NginxContainer(NGINX_IMAGE) + .withCopyFileToContainer(MountableFile.forHostPath(tmpDirectory), "/usr/share/nginx/html") + .waitingFor(new HttpWaitStrategy()); + // } + ) { + nginx.start(); + // getFromNginxServer { + URL baseUrl = nginx.getBaseUrl("http", 80); + + assertThat(responseFromNginx(baseUrl)) + .as("An HTTP GET from the Nginx server returns the index.html from the custom content directory") + .contains("Hello World!"); + // } + assertHasCorrectExposedAndLivenessCheckPorts(nginx); + } + } + + private void assertHasCorrectExposedAndLivenessCheckPorts(NginxContainer nginxContainer) { + assertThat(nginxContainer.getExposedPorts()).containsExactly(80); + assertThat(nginxContainer.getLivenessCheckPortNumbers()).containsExactly(nginxContainer.getMappedPort(80)); + } + + private static String responseFromNginx(URL baseUrl) throws IOException { + URLConnection urlConnection = baseUrl.openConnection(); + @Cleanup + BufferedReader reader = new BufferedReader(new InputStreamReader(urlConnection.getInputStream())); + return reader.readLine(); + } +} diff --git a/modules/nginx/src/test/resources/logback-test.xml b/modules/nginx/src/test/resources/logback-test.xml index ed0e5b2659e..83ef7a1a3ef 100644 --- a/modules/nginx/src/test/resources/logback-test.xml +++ b/modules/nginx/src/test/resources/logback-test.xml @@ -8,22 +8,9 @@ - + - - - - - - - - - - - - PROFILER - DENY - - \ No newline at end of file + + diff --git a/modules/oceanbase/build.gradle b/modules/oceanbase/build.gradle new file mode 100644 index 00000000000..f570d741f7b --- /dev/null +++ b/modules/oceanbase/build.gradle @@ -0,0 +1,8 @@ +description = "Testcontainers :: JDBC :: OceanBase" + +dependencies { + api project(':testcontainers-jdbc') + + testImplementation project(':testcontainers-jdbc-test') + testRuntimeOnly 'com.mysql:mysql-connector-j:9.6.0' +} diff --git a/modules/oceanbase/src/main/java/org/testcontainers/oceanbase/OceanBaseCEContainer.java b/modules/oceanbase/src/main/java/org/testcontainers/oceanbase/OceanBaseCEContainer.java new file mode 100644 index 00000000000..fae9190ceb0 --- /dev/null +++ b/modules/oceanbase/src/main/java/org/testcontainers/oceanbase/OceanBaseCEContainer.java @@ -0,0 +1,142 @@ +package org.testcontainers.oceanbase; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for OceanBase Community Edition. + *

+ * Supported image: {@code oceanbase/oceanbase-ce} + *

+ * Exposed ports: + *

    + *
  • SQL: 2881
  • + *
  • RPC: 2882
  • + *
+ */ +public class OceanBaseCEContainer extends JdbcDatabaseContainer { + + static final String NAME = "oceanbasece"; + + static final String DOCKER_IMAGE_NAME = "oceanbase/oceanbase-ce"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse(DOCKER_IMAGE_NAME); + + private static final Integer SQL_PORT = 2881; + + private static final Integer RPC_PORT = 2882; + + private static final String DEFAULT_TENANT_NAME = "test"; + + private static final String DEFAULT_USER = "root"; + + private static final String DEFAULT_PASSWORD = ""; + + private static final String DEFAULT_DATABASE_NAME = "test"; + + private Mode mode = Mode.SLIM; + + private String tenantName = DEFAULT_TENANT_NAME; + + private String password = DEFAULT_PASSWORD; + + public OceanBaseCEContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public OceanBaseCEContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + addExposedPorts(SQL_PORT, RPC_PORT); + setWaitStrategy(Wait.forLogMessage(".*boot success!.*", 1)); + } + + @Override + protected void configure() { + addEnv("MODE", mode.name().toLowerCase()); + + if (!DEFAULT_TENANT_NAME.equals(tenantName)) { + if (mode == Mode.SLIM) { + logger().warn("The tenant name is not configurable on slim mode, so this option will be ignored."); + // reset the tenant name to ensure the constructed username is correct + tenantName = DEFAULT_TENANT_NAME; + } else { + addEnv("OB_TENANT_NAME", tenantName); + } + } + + addEnv("OB_TENANT_PASSWORD", password); + } + + @Override + protected void waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this); + } + + @Override + public String getDriverClassName() { + return OceanBaseJdbcUtils.getDriverClass(); + } + + @Override + public String getJdbcUrl() { + String additionalUrlParams = constructUrlParameters("?", "&"); + String prefix = OceanBaseJdbcUtils.isMySQLDriver(getDriverClassName()) ? "jdbc:mysql://" : "jdbc:oceanbase://"; + return prefix + getHost() + ":" + getMappedPort(SQL_PORT) + "/" + DEFAULT_DATABASE_NAME + additionalUrlParams; + } + + @Override + public String getDatabaseName() { + return DEFAULT_DATABASE_NAME; + } + + @Override + public String getUsername() { + return DEFAULT_USER + "@" + tenantName; + } + + @Override + public String getPassword() { + return password; + } + + @Override + protected String getTestQueryString() { + return "SELECT 1"; + } + + public OceanBaseCEContainer withMode(Mode mode) { + this.mode = mode; + return this; + } + + public OceanBaseCEContainer withTenantName(String tenantName) { + this.tenantName = tenantName; + return this; + } + + public OceanBaseCEContainer withPassword(String password) { + this.password = password; + return this; + } + + public enum Mode { + /** + * Use as much hardware resources as possible for deployment by default, + * and all environment variables are available. + */ + NORMAL, + /** + * Use the minimum hardware resources for deployment by default, + * and all environment variables are available. + */ + MINI, + /** + * Use minimal hardware resources and pre-built deployment files for quick startup, + * and password of user tenant is the only available environment variable. + */ + SLIM, + } +} diff --git a/modules/oceanbase/src/main/java/org/testcontainers/oceanbase/OceanBaseCEContainerProvider.java b/modules/oceanbase/src/main/java/org/testcontainers/oceanbase/OceanBaseCEContainerProvider.java new file mode 100644 index 00000000000..2a62558b6c4 --- /dev/null +++ b/modules/oceanbase/src/main/java/org/testcontainers/oceanbase/OceanBaseCEContainerProvider.java @@ -0,0 +1,32 @@ +package org.testcontainers.oceanbase; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.JdbcDatabaseContainerProvider; +import org.testcontainers.utility.DockerImageName; + +/** + * Factory for OceanBase Community Edition containers. + */ +public class OceanBaseCEContainerProvider extends JdbcDatabaseContainerProvider { + + private static final String DEFAULT_TAG = "4.2.1.8-108000022024072217"; + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(OceanBaseCEContainer.NAME); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(DEFAULT_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + if (tag != null) { + return new OceanBaseCEContainer(DockerImageName.parse(OceanBaseCEContainer.DOCKER_IMAGE_NAME).withTag(tag)); + } else { + return newInstance(); + } + } +} diff --git a/modules/oceanbase/src/main/java/org/testcontainers/oceanbase/OceanBaseJdbcUtils.java b/modules/oceanbase/src/main/java/org/testcontainers/oceanbase/OceanBaseJdbcUtils.java new file mode 100644 index 00000000000..d2e90f48ee6 --- /dev/null +++ b/modules/oceanbase/src/main/java/org/testcontainers/oceanbase/OceanBaseJdbcUtils.java @@ -0,0 +1,45 @@ +package org.testcontainers.oceanbase; + +import java.util.Arrays; +import java.util.List; + +/** + * Utils for OceanBase Jdbc Connection. + */ +class OceanBaseJdbcUtils { + + static final String MYSQL_JDBC_DRIVER = "com.mysql.cj.jdbc.Driver"; + + static final String MYSQL_LEGACY_JDBC_DRIVER = "com.mysql.jdbc.Driver"; + + static final String OCEANBASE_JDBC_DRIVER = "com.oceanbase.jdbc.Driver"; + + static final String OCEANBASE_LEGACY_JDBC_DRIVER = "com.alipay.oceanbase.jdbc.Driver"; + + static final List SUPPORTED_DRIVERS = Arrays.asList( + OCEANBASE_JDBC_DRIVER, + OCEANBASE_LEGACY_JDBC_DRIVER, + MYSQL_JDBC_DRIVER, + MYSQL_LEGACY_JDBC_DRIVER + ); + + static String getDriverClass() { + for (String driverClass : SUPPORTED_DRIVERS) { + try { + Class.forName(driverClass); + return driverClass; + } catch (ClassNotFoundException e) { + // try to load next driver + } + } + throw new RuntimeException("Can't find valid driver class for OceanBase"); + } + + static boolean isMySQLDriver(String driverClassName) { + return MYSQL_JDBC_DRIVER.equals(driverClassName) || MYSQL_LEGACY_JDBC_DRIVER.equals(driverClassName); + } + + static boolean isOceanBaseDriver(String driverClassName) { + return OCEANBASE_JDBC_DRIVER.equals(driverClassName) || OCEANBASE_LEGACY_JDBC_DRIVER.equals(driverClassName); + } +} diff --git a/modules/oceanbase/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/oceanbase/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 00000000000..505bfe5e088 --- /dev/null +++ b/modules/oceanbase/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.oceanbase.OceanBaseCEContainerProvider diff --git a/modules/oceanbase/src/test/java/org/testcontainers/oceanbase/OceanBaseJdbcDriverTest.java b/modules/oceanbase/src/test/java/org/testcontainers/oceanbase/OceanBaseJdbcDriverTest.java new file mode 100644 index 00000000000..b701d953e9a --- /dev/null +++ b/modules/oceanbase/src/test/java/org/testcontainers/oceanbase/OceanBaseJdbcDriverTest.java @@ -0,0 +1,15 @@ +package org.testcontainers.oceanbase; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +class OceanBaseJdbcDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { { "jdbc:tc:oceanbasece://hostname/databasename", EnumSet.noneOf(Options.class) } } + ); + } +} diff --git a/modules/oceanbase/src/test/java/org/testcontainers/oceanbase/SimpleOceanBaseCETest.java b/modules/oceanbase/src/test/java/org/testcontainers/oceanbase/SimpleOceanBaseCETest.java new file mode 100644 index 00000000000..ee2d8196718 --- /dev/null +++ b/modules/oceanbase/src/test/java/org/testcontainers/oceanbase/SimpleOceanBaseCETest.java @@ -0,0 +1,62 @@ +package org.testcontainers.oceanbase; + +import org.junit.jupiter.api.Test; +import org.testcontainers.db.AbstractContainerDatabaseTest; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +class SimpleOceanBaseCETest extends AbstractContainerDatabaseTest { + + private static final String IMAGE = "oceanbase/oceanbase-ce:4.2.1.8-108000022024072217"; + + @Test + void testSimple() throws SQLException { + try ( // container { + OceanBaseCEContainer oceanbase = new OceanBaseCEContainer( + "oceanbase/oceanbase-ce:4.2.1.8-108000022024072217" + ) + // } + ) { + oceanbase.start(); + + ResultSet resultSet = performQuery(oceanbase, "SELECT 1"); + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + assertHasCorrectExposedAndLivenessCheckPorts(oceanbase); + } + } + + @Test + void testExplicitInitScript() throws SQLException { + try (OceanBaseCEContainer oceanbase = new OceanBaseCEContainer(IMAGE).withInitScript("init.sql")) { + oceanbase.start(); + + ResultSet resultSet = performQuery(oceanbase, "SELECT foo FROM bar"); + String firstColumnValue = resultSet.getString(1); + assertThat(firstColumnValue).as("Value from init script should equal real value").isEqualTo("hello world"); + } + } + + @Test + void testWithAdditionalUrlParamInJdbcUrl() { + try (OceanBaseCEContainer oceanbase = new OceanBaseCEContainer(IMAGE).withUrlParam("useSSL", "false")) { + oceanbase.start(); + + String jdbcUrl = oceanbase.getJdbcUrl(); + assertThat(jdbcUrl).contains("?"); + assertThat(jdbcUrl).contains("useSSL=false"); + } + } + + private void assertHasCorrectExposedAndLivenessCheckPorts(OceanBaseCEContainer oceanbase) { + int sqlPort = 2881; + int rpcPort = 2882; + + assertThat(oceanbase.getExposedPorts()).containsExactlyInAnyOrder(sqlPort, rpcPort); + assertThat(oceanbase.getLivenessCheckPortNumbers()) + .containsExactlyInAnyOrder(oceanbase.getMappedPort(sqlPort), oceanbase.getMappedPort(rpcPort)); + } +} diff --git a/modules/oceanbase/src/test/resources/init.sql b/modules/oceanbase/src/test/resources/init.sql new file mode 100644 index 00000000000..98d6889b078 --- /dev/null +++ b/modules/oceanbase/src/test/resources/init.sql @@ -0,0 +1,45 @@ +CREATE TABLE bar ( + foo VARCHAR(255) +); + +DROP PROCEDURE IF EXISTS -- ; + count_foo; + +SELECT "a /* string literal containing comment characters like -- here"; +SELECT "a 'quoting' \"scenario ` involving BEGIN keyword\" here"; +SELECT * from `bar`; + +-- What about a line comment containing imbalanced string delimiters? " + +CREATE PROCEDURE count_foo() +BEGIN + + BEGIN + SELECT * + FROM bar; + SELECT 1 + FROM dual; + END; + + BEGIN + select * from bar; + END; + + -- we can do comments + + /* including block + comments + */ + + /* what if BEGIN appears inside a comment? */ + + select "or what if BEGIN appears inside a literal?"; + +END /*; */; + +/* or a block comment + containing imbalanced string delimiters? + ' " + */ + +INSERT INTO bar (foo) /* ; */ VALUES ('hello world'); diff --git a/modules/oceanbase/src/test/resources/logback-test.xml b/modules/oceanbase/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/oceanbase/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/ollama/build.gradle b/modules/ollama/build.gradle new file mode 100644 index 00000000000..0da01d0ad96 --- /dev/null +++ b/modules/ollama/build.gradle @@ -0,0 +1,7 @@ +description = "Testcontainers :: Ollama" + +dependencies { + api project(':testcontainers') + + testImplementation 'io.rest-assured:rest-assured:5.5.7' +} diff --git a/modules/ollama/src/main/java/org/testcontainers/ollama/OllamaContainer.java b/modules/ollama/src/main/java/org/testcontainers/ollama/OllamaContainer.java new file mode 100644 index 00000000000..db611a34a39 --- /dev/null +++ b/modules/ollama/src/main/java/org/testcontainers/ollama/OllamaContainer.java @@ -0,0 +1,86 @@ +package org.testcontainers.ollama; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.model.DeviceRequest; +import com.github.dockerjava.api.model.Image; +import com.github.dockerjava.api.model.Info; +import com.github.dockerjava.api.model.RuntimeInfo; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Testcontainers implementation for Ollama. + *

+ * Supported image: {@code ollama/ollama} + *

+ * Exposed ports: 11434 + */ +public class OllamaContainer extends GenericContainer { + + private static final DockerImageName DOCKER_IMAGE_NAME = DockerImageName.parse("ollama/ollama"); + + private static final int OLLAMA_PORT = 11434; + + public OllamaContainer(String image) { + this(DockerImageName.parse(image)); + } + + public OllamaContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DOCKER_IMAGE_NAME); + + Info info = this.dockerClient.infoCmd().exec(); + Map runtimes = info.getRuntimes(); + if (runtimes != null) { + if (runtimes.containsKey("nvidia")) { + withCreateContainerCmdModifier(cmd -> { + cmd + .getHostConfig() + .withDeviceRequests( + Collections.singletonList( + new DeviceRequest() + .withCapabilities(Collections.singletonList(Collections.singletonList("gpu"))) + .withCount(-1) + ) + ); + }); + } + } + withExposedPorts(OLLAMA_PORT); + } + + /** + * Commits the current file system changes in the container into a new image. + * Should be used for creating an image that contains a loaded model. + * @param imageName the name of the new image + */ + public void commitToImage(String imageName) { + DockerImageName dockerImageName = DockerImageName.parse(getDockerImageName()); + if (!dockerImageName.equals(DockerImageName.parse(imageName))) { + DockerClient dockerClient = DockerClientFactory.instance().client(); + List images = dockerClient.listImagesCmd().withReferenceFilter(imageName).exec(); + if (images.isEmpty()) { + DockerImageName imageModel = DockerImageName.parse(imageName); + dockerClient + .commitCmd(getContainerId()) + .withRepository(imageModel.getUnversionedPart()) + .withLabels(Collections.singletonMap("org.testcontainers.sessionId", "")) + .withTag(imageModel.getVersionPart()) + .exec(); + } + } + } + + public int getPort() { + return getMappedPort(OLLAMA_PORT); + } + + public String getEndpoint() { + return "http://" + getHost() + ":" + getPort(); + } +} diff --git a/modules/ollama/src/test/java/org/testcontainers/ollama/OllamaContainerTest.java b/modules/ollama/src/test/java/org/testcontainers/ollama/OllamaContainerTest.java new file mode 100644 index 00000000000..dcd61cad9c4 --- /dev/null +++ b/modules/ollama/src/test/java/org/testcontainers/ollama/OllamaContainerTest.java @@ -0,0 +1,65 @@ +package org.testcontainers.ollama; + +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.Base58; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; + +class OllamaContainerTest { + + @Test + void withDefaultConfig() { + try ( // container { + OllamaContainer ollama = new OllamaContainer("ollama/ollama:0.1.26") + // } + ) { + ollama.start(); + + String version = given().baseUri(ollama.getEndpoint()).get("/api/version").jsonPath().get("version"); + assertThat(version).isEqualTo("0.1.26"); + } + } + + @Test + void downloadModelAndCommitToImage() throws IOException, InterruptedException { + String newImageName = "tc-ollama-allminilm-" + Base58.randomString(4).toLowerCase(); + try (OllamaContainer ollama = new OllamaContainer("ollama/ollama:0.1.26")) { + ollama.start(); + // pullModel { + ollama.execInContainer("ollama", "pull", "all-minilm"); + // } + + String modelName = given() + .baseUri(ollama.getEndpoint()) + .get("/api/tags") + .jsonPath() + .getString("models[0].name"); + assertThat(modelName).contains("all-minilm"); + // commitToImage { + ollama.commitToImage(newImageName); + // } + } + try ( + // spotless:off + // substitute { + OllamaContainer ollama = new OllamaContainer( + DockerImageName.parse(newImageName) + .asCompatibleSubstituteFor("ollama/ollama") + ) + // } + // spotless:on + ) { + ollama.start(); + String modelName = given() + .baseUri(ollama.getEndpoint()) + .get("/api/tags") + .jsonPath() + .getString("models[0].name"); + assertThat(modelName).contains("all-minilm"); + } + } +} diff --git a/modules/ollama/src/test/resources/logback-test.xml b/modules/ollama/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/ollama/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/openfga/build.gradle b/modules/openfga/build.gradle new file mode 100644 index 00000000000..b6bf9ff8e70 --- /dev/null +++ b/modules/openfga/build.gradle @@ -0,0 +1,20 @@ +description = "Testcontainers :: OpenFGA" + +dependencies { + api project(':testcontainers') + + testImplementation 'dev.openfga:openfga-sdk:0.9.9' +} + +test { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(17) + } +} + +compileTestJava { + javaCompiler = javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(17) + } + options.release.set(17) +} diff --git a/modules/openfga/src/main/java/org/testcontainers/openfga/OpenFGAContainer.java b/modules/openfga/src/main/java/org/testcontainers/openfga/OpenFGAContainer.java new file mode 100644 index 00000000000..986e385c0d3 --- /dev/null +++ b/modules/openfga/src/main/java/org/testcontainers/openfga/OpenFGAContainer.java @@ -0,0 +1,45 @@ +package org.testcontainers.openfga; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for OpenFGA. + *

+ * Supported image: {@code openfga/openfga} + *

+ * Exposed ports: + *

    + *
  • Playground: 3000
  • + *
  • HTTP: 8080
  • + *
  • gRPC: 8081
  • + *
+ */ +public class OpenFGAContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("openfga/openfga"); + + public OpenFGAContainer(String image) { + this(DockerImageName.parse(image)); + } + + public OpenFGAContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + withExposedPorts(3000, 8080, 8081); + withCommand("run"); + waitingFor( + Wait.forHttp("/healthz").forPort(8080).forResponsePredicate(response -> response.contains("SERVING")) + ); + } + + public String getHttpEndpoint() { + return "http://" + getHost() + ":" + getMappedPort(8080); + } + + public String getGrpcEndpoint() { + return "http://" + getHost() + ":" + getMappedPort(8081); + } +} diff --git a/modules/openfga/src/test/java/org/testcontainers/openfga/OpenFGAContainerTest.java b/modules/openfga/src/test/java/org/testcontainers/openfga/OpenFGAContainerTest.java new file mode 100644 index 00000000000..be68bdee71a --- /dev/null +++ b/modules/openfga/src/test/java/org/testcontainers/openfga/OpenFGAContainerTest.java @@ -0,0 +1,33 @@ +package org.testcontainers.openfga; + +import dev.openfga.sdk.api.client.OpenFgaClient; +import dev.openfga.sdk.api.client.model.ClientCreateStoreResponse; +import dev.openfga.sdk.api.configuration.ClientConfiguration; +import dev.openfga.sdk.api.model.CreateStoreRequest; +import dev.openfga.sdk.errors.FgaInvalidParameterException; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.ExecutionException; + +import static org.assertj.core.api.Assertions.assertThat; + +class OpenFGAContainerTest { + + @Test + void withDefaultConfig() throws FgaInvalidParameterException, ExecutionException, InterruptedException { + try ( // container { + OpenFGAContainer openfga = new OpenFGAContainer("openfga/openfga:v1.4.3") + // } + ) { + openfga.start(); + + ClientConfiguration config = new ClientConfiguration().apiUrl(openfga.getHttpEndpoint()); + OpenFgaClient client = new OpenFgaClient(config); + + assertThat(client.listStores().get().getStores()).isEmpty(); + ClientCreateStoreResponse store = client.createStore(new CreateStoreRequest().name("test")).get(); + assertThat(store.getId()).isNotNull(); + assertThat(client.listStores().get().getStores()).hasSize(1); + } + } +} diff --git a/modules/openfga/src/test/resources/logback-test.xml b/modules/openfga/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/openfga/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/oracle-free/build.gradle b/modules/oracle-free/build.gradle new file mode 100644 index 00000000000..935fe701f6f --- /dev/null +++ b/modules/oracle-free/build.gradle @@ -0,0 +1,16 @@ +description = "Testcontainers :: JDBC :: Oracle Database Free" + +dependencies { + api project(':testcontainers-jdbc') + + compileOnly project(':testcontainers-r2dbc') + compileOnly 'com.oracle.database.r2dbc:oracle-r2dbc:1.3.0' + + testImplementation project(':testcontainers-jdbc-test') + testImplementation 'com.oracle.database.jdbc:ojdbc11:23.26.2.0.0' + + compileOnly 'org.jetbrains:annotations:26.1.0' + + testImplementation testFixtures(project(':testcontainers-r2dbc')) + testRuntimeOnly 'com.oracle.database.r2dbc:oracle-r2dbc:1.3.0' +} diff --git a/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleContainer.java b/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleContainer.java new file mode 100644 index 00000000000..2080c24283b --- /dev/null +++ b/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleContainer.java @@ -0,0 +1,198 @@ +package org.testcontainers.oracle; + +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * Testcontainers implementation for Oracle Database Free. + *

+ * Supported image: {@code gvenzl/oracle-free} + *

+ * Exposed ports: 1521 + */ +public class OracleContainer extends JdbcDatabaseContainer { + + static final String NAME = "oracle"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("gvenzl/oracle-free"); + + static final String DEFAULT_TAG = "slim"; + + static final String IMAGE = DEFAULT_IMAGE_NAME.getUnversionedPart(); + + static final int ORACLE_PORT = 1521; + + private static final int DEFAULT_STARTUP_TIMEOUT_SECONDS = 60; + + private static final int DEFAULT_CONNECT_TIMEOUT_SECONDS = 60; + + // Container defaults + static final String DEFAULT_DATABASE_NAME = "freepdb1"; + + static final String DEFAULT_SID = "free"; + + static final String DEFAULT_SYSTEM_USER = "system"; + + static final String DEFAULT_SYS_USER = "sys"; + + // Test container defaults + static final String APP_USER = "test"; + + static final String APP_USER_PASSWORD = "test"; + + // Restricted user and database names + private static final List ORACLE_SYSTEM_USERS = Arrays.asList(DEFAULT_SYSTEM_USER, DEFAULT_SYS_USER); + + private String databaseName = DEFAULT_DATABASE_NAME; + + private String username = APP_USER; + + private String password = APP_USER_PASSWORD; + + private boolean usingSid = false; + + public OracleContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public OracleContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + waitingFor( + Wait + .forLogMessage(".*DATABASE IS READY TO USE!.*\\s", 1) + .withStartupTimeout(Duration.ofSeconds(DEFAULT_STARTUP_TIMEOUT_SECONDS)) + ); + withConnectTimeoutSeconds(DEFAULT_CONNECT_TIMEOUT_SECONDS); + addExposedPorts(ORACLE_PORT); + } + + @Override + protected void waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this); + } + + @NotNull + @Override + public Set getLivenessCheckPortNumbers() { + return Collections.singleton(getMappedPort(ORACLE_PORT)); + } + + @Override + public String getDriverClassName() { + try { + Class.forName("oracle.jdbc.OracleDriver"); + return "oracle.jdbc.OracleDriver"; + } catch (ClassNotFoundException e) { + return "oracle.jdbc.driver.OracleDriver"; + } + } + + @Override + public String getJdbcUrl() { + return isUsingSid() + ? "jdbc:oracle:thin:" + "@" + getHost() + ":" + getOraclePort() + ":" + getSid() + : "jdbc:oracle:thin:" + "@" + getHost() + ":" + getOraclePort() + "/" + getDatabaseName(); + } + + @Override + public String getUsername() { + // An application user is tied to the database, and therefore not authenticated to connect to SID. + return isUsingSid() ? DEFAULT_SYSTEM_USER : username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getDatabaseName() { + return databaseName; + } + + protected boolean isUsingSid() { + return usingSid; + } + + @Override + public OracleContainer withUsername(String username) { + if (StringUtils.isEmpty(username)) { + throw new IllegalArgumentException("Username cannot be null or empty"); + } + if (ORACLE_SYSTEM_USERS.contains(username.toLowerCase())) { + throw new IllegalArgumentException("Username cannot be one of " + ORACLE_SYSTEM_USERS); + } + this.username = username; + return self(); + } + + @Override + public OracleContainer withPassword(String password) { + if (StringUtils.isEmpty(password)) { + throw new IllegalArgumentException("Password cannot be null or empty"); + } + this.password = password; + return self(); + } + + @Override + public OracleContainer withDatabaseName(String databaseName) { + if (StringUtils.isEmpty(databaseName)) { + throw new IllegalArgumentException("Database name cannot be null or empty"); + } + + if (DEFAULT_DATABASE_NAME.equals(databaseName.toLowerCase())) { + throw new IllegalArgumentException("Database name cannot be set to " + DEFAULT_DATABASE_NAME); + } + + this.databaseName = databaseName; + return self(); + } + + public OracleContainer usingSid() { + this.usingSid = true; + return self(); + } + + @Override + public OracleContainer withUrlParam(String paramName, String paramValue) { + throw new UnsupportedOperationException("The Oracle Database driver does not support this"); + } + + @SuppressWarnings("SameReturnValue") + public String getSid() { + return DEFAULT_SID; + } + + public Integer getOraclePort() { + return getMappedPort(ORACLE_PORT); + } + + @Override + public String getTestQueryString() { + return "SELECT 1 FROM DUAL"; + } + + @Override + protected void configure() { + withEnv("ORACLE_PASSWORD", password); + + // Only set ORACLE_DATABASE if different than the default. + if (databaseName != DEFAULT_DATABASE_NAME) { + withEnv("ORACLE_DATABASE", databaseName); + } + + withEnv("APP_USER", username); + withEnv("APP_USER_PASSWORD", password); + } +} diff --git a/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleContainerProvider.java b/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleContainerProvider.java new file mode 100644 index 00000000000..031d3471cc5 --- /dev/null +++ b/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleContainerProvider.java @@ -0,0 +1,29 @@ +package org.testcontainers.oracle; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.JdbcDatabaseContainerProvider; +import org.testcontainers.utility.DockerImageName; + +/** + * Factory for Oracle containers. + */ +public class OracleContainerProvider extends JdbcDatabaseContainerProvider { + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(OracleContainer.NAME); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(OracleContainer.DEFAULT_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + if (tag != null) { + return new OracleContainer(DockerImageName.parse(OracleContainer.IMAGE).withTag(tag)); + } + return newInstance(); + } +} diff --git a/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleR2DBCDatabaseContainer.java b/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleR2DBCDatabaseContainer.java new file mode 100644 index 00000000000..ae480027f25 --- /dev/null +++ b/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleR2DBCDatabaseContainer.java @@ -0,0 +1,57 @@ +package org.testcontainers.oracle; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; + +import java.util.Set; + +public class OracleR2DBCDatabaseContainer implements R2DBCDatabaseContainer { + + private final OracleContainer container; + + public OracleR2DBCDatabaseContainer(OracleContainer container) { + this.container = container; + } + + public static ConnectionFactoryOptions getOptions(OracleContainer container) { + ConnectionFactoryOptions options = ConnectionFactoryOptions + .builder() + .option(ConnectionFactoryOptions.DRIVER, OracleR2DBCDatabaseContainerProvider.DRIVER) + .build(); + + return new OracleR2DBCDatabaseContainer(container).configure(options); + } + + @Override + public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { + return options + .mutate() + .option(ConnectionFactoryOptions.HOST, container.getHost()) + .option(ConnectionFactoryOptions.PORT, container.getMappedPort(OracleContainer.ORACLE_PORT)) + .option(ConnectionFactoryOptions.DATABASE, container.getDatabaseName()) + .option(ConnectionFactoryOptions.USER, container.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, container.getPassword()) + .build(); + } + + @Override + public Set getDependencies() { + return this.container.getDependencies(); + } + + @Override + public void start() { + this.container.start(); + } + + @Override + public void stop() { + this.container.stop(); + } + + @Override + public void close() { + this.container.close(); + } +} diff --git a/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleR2DBCDatabaseContainerProvider.java b/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleR2DBCDatabaseContainerProvider.java new file mode 100644 index 00000000000..d8fe1ee7084 --- /dev/null +++ b/modules/oracle-free/src/main/java/org/testcontainers/oracle/OracleR2DBCDatabaseContainerProvider.java @@ -0,0 +1,41 @@ +package org.testcontainers.oracle; + +import io.r2dbc.spi.ConnectionFactoryMetadata; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.jetbrains.annotations.Nullable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; +import org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider; + +public class OracleR2DBCDatabaseContainerProvider implements R2DBCDatabaseContainerProvider { + + static final String DRIVER = "oracle"; + + @Override + public boolean supports(ConnectionFactoryOptions options) { + return DRIVER.equals(options.getRequiredValue(ConnectionFactoryOptions.DRIVER)); + } + + @Override + public R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options) { + String image = OracleContainer.IMAGE + ":" + options.getRequiredValue(IMAGE_TAG_OPTION); + OracleContainer container = new OracleContainer(image) + .withDatabaseName((String) options.getRequiredValue(ConnectionFactoryOptions.DATABASE)); + if (Boolean.TRUE.equals(options.getValue(REUSABLE_OPTION))) { + container.withReuse(true); + } + return new OracleR2DBCDatabaseContainer(container); + } + + @Nullable + @Override + public ConnectionFactoryMetadata getMetadata(ConnectionFactoryOptions options) { + ConnectionFactoryOptions.Builder builder = options.mutate(); + if (!options.hasOption(ConnectionFactoryOptions.USER)) { + builder.option(ConnectionFactoryOptions.USER, OracleContainer.APP_USER); + } + if (!options.hasOption(ConnectionFactoryOptions.PASSWORD)) { + builder.option(ConnectionFactoryOptions.PASSWORD, OracleContainer.APP_USER_PASSWORD); + } + return R2DBCDatabaseContainerProvider.super.getMetadata(builder.build()); + } +} diff --git a/modules/oracle-free/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/oracle-free/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 00000000000..fd3e21bca3d --- /dev/null +++ b/modules/oracle-free/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.oracle.OracleContainerProvider diff --git a/modules/oracle-free/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider b/modules/oracle-free/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider new file mode 100644 index 00000000000..20465a3e57b --- /dev/null +++ b/modules/oracle-free/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.oracle.OracleR2DBCDatabaseContainerProvider diff --git a/modules/oracle-free/src/test/java/org/testcontainers/junit/oracle/SimpleOracleTest.java b/modules/oracle-free/src/test/java/org/testcontainers/junit/oracle/SimpleOracleTest.java new file mode 100644 index 00000000000..f46539bac89 --- /dev/null +++ b/modules/oracle-free/src/test/java/org/testcontainers/junit/oracle/SimpleOracleTest.java @@ -0,0 +1,146 @@ +package org.testcontainers.junit.oracle; + +import org.junit.jupiter.api.Test; +import org.testcontainers.db.AbstractContainerDatabaseTest; +import org.testcontainers.oracle.OracleContainer; +import org.testcontainers.utility.DockerImageName; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +class SimpleOracleTest extends AbstractContainerDatabaseTest { + + private static final DockerImageName ORACLE_DOCKER_IMAGE_NAME = DockerImageName.parse( + "gvenzl/oracle-free:slim-faststart" + ); + + private void runTest(OracleContainer container, String databaseName, String username, String password) + throws SQLException { + //Test config was honored + assertThat(container.getDatabaseName()).isEqualTo(databaseName); + assertThat(container.getUsername()).isEqualTo(username); + assertThat(container.getPassword()).isEqualTo(password); + + //Test we can get a connection + container.start(); + ResultSet resultSet = performQuery(container, "SELECT 1 FROM dual"); + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + } + + @Test + void testDefaultSettings() throws SQLException { + try ( // container { + OracleContainer oracle = new OracleContainer("gvenzl/oracle-free:slim-faststart") + // } + ) { + runTest(oracle, "freepdb1", "test", "test"); + + // Match against the last '/' + String urlSuffix = oracle.getJdbcUrl().split("(\\/)(?!.*\\/)", 2)[1]; + assertThat(urlSuffix).isEqualTo("freepdb1"); + } + } + + @Test + void testPluggableDatabase() throws SQLException { + try (OracleContainer oracle = new OracleContainer(ORACLE_DOCKER_IMAGE_NAME).withDatabaseName("testDB")) { + runTest(oracle, "testDB", "test", "test"); + } + } + + @Test + void testPluggableDatabaseAndCustomUser() throws SQLException { + try ( + OracleContainer oracle = new OracleContainer("gvenzl/oracle-free:slim-faststart") + .withDatabaseName("testDB") + .withUsername("testUser") + .withPassword("testPassword") + ) { + runTest(oracle, "testDB", "testUser", "testPassword"); + } + } + + @Test + void testCustomUser() throws SQLException { + try ( + OracleContainer oracle = new OracleContainer(ORACLE_DOCKER_IMAGE_NAME) + .withUsername("testUser") + .withPassword("testPassword") + ) { + runTest(oracle, "freepdb1", "testUser", "testPassword"); + } + } + + @Test + void testSID() throws SQLException { + try (OracleContainer oracle = new OracleContainer(ORACLE_DOCKER_IMAGE_NAME).usingSid()) { + runTest(oracle, "freepdb1", "system", "test"); + + // Match against the last ':' + String urlSuffix = oracle.getJdbcUrl().split("(\\:)(?!.*\\:)", 2)[1]; + assertThat(urlSuffix).isEqualTo("free"); + } + } + + @Test + void testSIDAndCustomPassword() throws SQLException { + try ( + OracleContainer oracle = new OracleContainer(ORACLE_DOCKER_IMAGE_NAME) + .usingSid() + .withPassword("testPassword") + ) { + runTest(oracle, "freepdb1", "system", "testPassword"); + } + } + + @Test + void testErrorPaths() throws SQLException { + try (OracleContainer oracle = new OracleContainer(ORACLE_DOCKER_IMAGE_NAME)) { + try { + oracle.withDatabaseName("FREEPDB1"); + fail("Should not have been able to set database name to freepdb1."); + } catch (IllegalArgumentException e) { + //expected + } + + try { + oracle.withDatabaseName(""); + fail("Should not have been able to set database name to nothing."); + } catch (IllegalArgumentException e) { + //expected + } + + try { + oracle.withUsername("SYSTEM"); + fail("Should not have been able to set username to system."); + } catch (IllegalArgumentException e) { + //expected + } + + try { + oracle.withUsername("SYS"); + fail("Should not have been able to set username to sys."); + } catch (IllegalArgumentException e) { + //expected + } + + try { + oracle.withUsername(""); + fail("Should not have been able to set username to nothing."); + } catch (IllegalArgumentException e) { + //expected + } + + try { + oracle.withPassword(""); + fail("Should not have been able to set password to nothing."); + } catch (IllegalArgumentException e) { + //expected + } + } + } +} diff --git a/modules/oracle-xe/src/test/java/org/testcontainers/containers/OracleJDBCDriverTest.java b/modules/oracle-free/src/test/java/org/testcontainers/oracle/jdbc/OracleJDBCDriverTest.java similarity index 53% rename from modules/oracle-xe/src/test/java/org/testcontainers/containers/OracleJDBCDriverTest.java rename to modules/oracle-free/src/test/java/org/testcontainers/oracle/jdbc/OracleJDBCDriverTest.java index 61a8d9b8d4b..a4982e966c1 100644 --- a/modules/oracle-xe/src/test/java/org/testcontainers/containers/OracleJDBCDriverTest.java +++ b/modules/oracle-free/src/test/java/org/testcontainers/oracle/jdbc/OracleJDBCDriverTest.java @@ -1,38 +1,38 @@ -package org.testcontainers.containers; +package org.testcontainers.oracle.jdbc; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import org.apache.commons.dbutils.QueryRunner; import org.apache.commons.dbutils.ResultSetHandler; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.sql.ResultSet; import java.sql.SQLException; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; -/** - * @author gusohal - */ -public class OracleJDBCDriverTest { +class OracleJDBCDriverTest { @Test - public void testOracleWithNoSpecifiedVersion() throws SQLException { + void testOracleWithNoSpecifiedVersion() throws SQLException { performSimpleTest("jdbc:tc:oracle://hostname/databasename"); } - private void performSimpleTest(String jdbcUrl) throws SQLException { HikariDataSource dataSource = getDataSource(jdbcUrl, 1); - new QueryRunner(dataSource).query("SELECT 1 FROM dual", new ResultSetHandler() { - @Override - public Object handle(ResultSet rs) throws SQLException { - rs.next(); - int resultSetInt = rs.getInt(1); - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - return true; - } - }); + new QueryRunner(dataSource) + .query( + "SELECT 1 FROM dual", + new ResultSetHandler() { + @Override + public Object handle(ResultSet rs) throws SQLException { + rs.next(); + int resultSetInt = rs.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + return true; + } + } + ); dataSource.close(); } diff --git a/modules/oracle-free/src/test/java/org/testcontainers/oracle/r2dbc/OracleR2DBCDatabaseContainerTest.java b/modules/oracle-free/src/test/java/org/testcontainers/oracle/r2dbc/OracleR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..a3933cdfe39 --- /dev/null +++ b/modules/oracle-free/src/test/java/org/testcontainers/oracle/r2dbc/OracleR2DBCDatabaseContainerTest.java @@ -0,0 +1,30 @@ +package org.testcontainers.oracle.r2dbc; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.oracle.OracleContainer; +import org.testcontainers.oracle.OracleR2DBCDatabaseContainer; +import org.testcontainers.r2dbc.AbstractR2DBCDatabaseContainerTest; + +public class OracleR2DBCDatabaseContainerTest extends AbstractR2DBCDatabaseContainerTest { + + @Override + protected OracleContainer createContainer() { + return new OracleContainer("gvenzl/oracle-free:slim-faststart"); + } + + @Override + protected ConnectionFactoryOptions getOptions(OracleContainer container) { + ConnectionFactoryOptions options = OracleR2DBCDatabaseContainer.getOptions(container); + + return options; + } + + protected String createR2DBCUrl() { + return "r2dbc:tc:oracle:///db?TC_IMAGE_TAG=slim-faststart"; + } + + @Override + protected String query() { + return "SELECT %s from dual"; + } +} diff --git a/modules/oracle-free/src/test/resources/logback-test.xml b/modules/oracle-free/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/oracle-free/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/oracle-xe/build.gradle b/modules/oracle-xe/build.gradle new file mode 100644 index 00000000000..1fb43310ae0 --- /dev/null +++ b/modules/oracle-xe/build.gradle @@ -0,0 +1,16 @@ +description = "Testcontainers :: JDBC :: Oracle XE" + +dependencies { + api project(':testcontainers-jdbc') + + compileOnly project(':testcontainers-r2dbc') + compileOnly 'com.oracle.database.r2dbc:oracle-r2dbc:1.3.0' + + testImplementation project(':testcontainers-jdbc-test') + testImplementation 'com.oracle.database.jdbc:ojdbc11:23.26.1.0.0' + + compileOnly 'org.jetbrains:annotations:26.1.0' + + testImplementation testFixtures(project(':testcontainers-r2dbc')) + testRuntimeOnly 'com.oracle.database.r2dbc:oracle-r2dbc:1.3.0' +} diff --git a/modules/oracle-xe/pom.xml b/modules/oracle-xe/pom.xml deleted file mode 100644 index c68885d1f1f..00000000000 --- a/modules/oracle-xe/pom.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - 4.0.0 - - oracle-xe - TestContainers :: JDBC :: Oracle - - - org.testcontainers - testcontainers-parent - 1.2.1-SNAPSHOT - ../../pom.xml - - - - - ${project.groupId} - testcontainers - ${project.version} - - - ${project.groupId} - jdbc - ${project.version} - - - - - com.zaxxer - HikariCP-java6 - 2.3.5 - test - - - commons-dbutils - commons-dbutils - 1.6 - test - - - - - com.oracle - ojdbc6 - 11.2.0.4 - provided - - - - - diff --git a/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleContainer.java b/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleContainer.java index f0fe016a5e9..75b16779f40 100644 --- a/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleContainer.java +++ b/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleContainer.java @@ -1,57 +1,198 @@ package org.testcontainers.containers; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Future; + /** - * @author gusohal + * Testcontainers implementation for Oracle. + *

+ * Supported image: {@code gvenzl/oracle-xe} + *

+ * Exposed ports: 1521 */ -public class OracleContainer extends JdbcDatabaseContainer { +public class OracleContainer extends JdbcDatabaseContainer { public static final String NAME = "oracle"; - public static final String IMAGE = "wnameless/oracle-xe-11g"; - private static final int ORACLE_PORT = 1521; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("gvenzl/oracle-xe"); + + static final String DEFAULT_TAG = "18.4.0-slim"; + + static final String IMAGE = DEFAULT_IMAGE_NAME.getUnversionedPart(); + + static final int ORACLE_PORT = 1521; + private static final int APEX_HTTP_PORT = 8080; + private static final int DEFAULT_STARTUP_TIMEOUT_SECONDS = 240; + + private static final int DEFAULT_CONNECT_TIMEOUT_SECONDS = 120; + + // Container defaults + static final String DEFAULT_DATABASE_NAME = "xepdb1"; + + static final String DEFAULT_SID = "xe"; + + static final String DEFAULT_SYSTEM_USER = "system"; + + static final String DEFAULT_SYS_USER = "sys"; + + // Test container defaults + static final String APP_USER = "test"; + + static final String APP_USER_PASSWORD = "test"; + + // Restricted user and database names + private static final List ORACLE_SYSTEM_USERS = Arrays.asList(DEFAULT_SYSTEM_USER, DEFAULT_SYS_USER); + + private String databaseName = DEFAULT_DATABASE_NAME; + + private String username = APP_USER; + + private String password = APP_USER_PASSWORD; + + private boolean usingSid = false; + + /** + * @deprecated use {@link #OracleContainer(DockerImageName)} instead + */ + @Deprecated public OracleContainer() { - super(IMAGE + ":latest"); + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); } public OracleContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public OracleContainer(final DockerImageName dockerImageName) { super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + preconfigure(); } - @Override - protected Integer getLivenessCheckPort() { - return getMappedPort(ORACLE_PORT); + public OracleContainer(Future dockerImageName) { + super(dockerImageName); + preconfigure(); } - @Override - protected void configure() { + private void preconfigure() { + this.waitStrategy = + new LogMessageWaitStrategy() + .withRegEx(".*DATABASE IS READY TO USE!.*\\s") + .withTimes(1) + .withStartupTimeout(Duration.of(DEFAULT_STARTUP_TIMEOUT_SECONDS, ChronoUnit.SECONDS)); + withConnectTimeoutSeconds(DEFAULT_CONNECT_TIMEOUT_SECONDS); addExposedPorts(ORACLE_PORT, APEX_HTTP_PORT); } + @Override + protected void waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this); + } + + @NotNull + @Override + public Set getLivenessCheckPortNumbers() { + return Collections.singleton(getMappedPort(ORACLE_PORT)); + } + @Override public String getDriverClassName() { - return "oracle.jdbc.OracleDriver"; + try { + Class.forName("oracle.jdbc.OracleDriver"); + return "oracle.jdbc.OracleDriver"; + } catch (ClassNotFoundException e) { + return "oracle.jdbc.driver.OracleDriver"; + } } @Override public String getJdbcUrl() { - return "jdbc:oracle:thin:" + getUsername() + "/" + getPassword() + "@//" + getContainerIpAddress() + ":" + getOraclePort() + "/" + getSid(); + return isUsingSid() + ? "jdbc:oracle:thin:" + "@" + getHost() + ":" + getOraclePort() + ":" + getSid() + : "jdbc:oracle:thin:" + "@" + getHost() + ":" + getOraclePort() + "/" + getDatabaseName(); } @Override public String getUsername() { - return "system"; + // An application user is tied to the database, and therefore not authenticated to connect to SID. + return isUsingSid() ? DEFAULT_SYSTEM_USER : username; } @Override public String getPassword() { - return "oracle"; + return password; + } + + @Override + public String getDatabaseName() { + return databaseName; + } + + protected boolean isUsingSid() { + return usingSid; + } + + @Override + public OracleContainer withUsername(String username) { + if (StringUtils.isEmpty(username)) { + throw new IllegalArgumentException("Username cannot be null or empty"); + } + if (ORACLE_SYSTEM_USERS.contains(username.toLowerCase())) { + throw new IllegalArgumentException("Username cannot be one of " + ORACLE_SYSTEM_USERS); + } + this.username = username; + return self(); + } + + @Override + public OracleContainer withPassword(String password) { + if (StringUtils.isEmpty(password)) { + throw new IllegalArgumentException("Password cannot be null or empty"); + } + this.password = password; + return self(); + } + + @Override + public OracleContainer withDatabaseName(String databaseName) { + if (StringUtils.isEmpty(databaseName)) { + throw new IllegalArgumentException("Database name cannot be null or empty"); + } + + if (DEFAULT_DATABASE_NAME.equals(databaseName.toLowerCase())) { + throw new IllegalArgumentException("Database name cannot be set to " + DEFAULT_DATABASE_NAME); + } + + this.databaseName = databaseName; + return self(); + } + + public OracleContainer usingSid() { + this.usingSid = true; + return self(); + } + + @Override + public OracleContainer withUrlParam(String paramName, String paramValue) { + throw new UnsupportedOperationException("The Oracle Database driver does not support this"); } @SuppressWarnings("SameReturnValue") public String getSid() { - return "xe"; + return DEFAULT_SID; } public Integer getOraclePort() { @@ -67,4 +208,17 @@ public Integer getWebPort() { public String getTestQueryString() { return "SELECT 1 FROM DUAL"; } -} \ No newline at end of file + + @Override + protected void configure() { + withEnv("ORACLE_PASSWORD", password); + + // Only set ORACLE_DATABASE if different than the default. + if (databaseName != DEFAULT_DATABASE_NAME) { + withEnv("ORACLE_DATABASE", databaseName); + } + + withEnv("APP_USER", username); + withEnv("APP_USER_PASSWORD", password); + } +} diff --git a/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleContainerProvider.java b/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleContainerProvider.java index 1aea9f7e443..f92f0ec40be 100644 --- a/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleContainerProvider.java +++ b/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleContainerProvider.java @@ -1,16 +1,27 @@ package org.testcontainers.containers; +import org.testcontainers.utility.DockerImageName; + /** * Factory for Oracle containers. */ public class OracleContainerProvider extends JdbcDatabaseContainerProvider { + @Override public boolean supports(String databaseType) { return databaseType.equals(OracleContainer.NAME); } + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(OracleContainer.DEFAULT_TAG); + } + @Override public JdbcDatabaseContainer newInstance(String tag) { - return new OracleContainer(OracleContainer.IMAGE + ":" + tag); + if (tag != null) { + return new OracleContainer(DockerImageName.parse(OracleContainer.IMAGE).withTag(tag)); + } + return newInstance(); } } diff --git a/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleR2DBCDatabaseContainer.java b/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleR2DBCDatabaseContainer.java new file mode 100644 index 00000000000..4268faf306b --- /dev/null +++ b/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleR2DBCDatabaseContainer.java @@ -0,0 +1,35 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; + +@RequiredArgsConstructor +public class OracleR2DBCDatabaseContainer implements R2DBCDatabaseContainer { + + @Delegate(types = Startable.class) + private final OracleContainer container; + + public static ConnectionFactoryOptions getOptions(OracleContainer container) { + ConnectionFactoryOptions options = ConnectionFactoryOptions + .builder() + .option(ConnectionFactoryOptions.DRIVER, OracleR2DBCDatabaseContainerProvider.DRIVER) + .build(); + + return new OracleR2DBCDatabaseContainer(container).configure(options); + } + + @Override + public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { + return options + .mutate() + .option(ConnectionFactoryOptions.HOST, container.getHost()) + .option(ConnectionFactoryOptions.PORT, container.getMappedPort(OracleContainer.ORACLE_PORT)) + .option(ConnectionFactoryOptions.DATABASE, container.getDatabaseName()) + .option(ConnectionFactoryOptions.USER, container.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, container.getPassword()) + .build(); + } +} diff --git a/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleR2DBCDatabaseContainerProvider.java b/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleR2DBCDatabaseContainerProvider.java new file mode 100644 index 00000000000..35a98fda77d --- /dev/null +++ b/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleR2DBCDatabaseContainerProvider.java @@ -0,0 +1,41 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryMetadata; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.jetbrains.annotations.Nullable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; +import org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider; + +public class OracleR2DBCDatabaseContainerProvider implements R2DBCDatabaseContainerProvider { + + static final String DRIVER = "oracle"; + + @Override + public boolean supports(ConnectionFactoryOptions options) { + return DRIVER.equals(options.getRequiredValue(ConnectionFactoryOptions.DRIVER)); + } + + @Override + public R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options) { + String image = OracleContainer.IMAGE + ":" + options.getRequiredValue(IMAGE_TAG_OPTION); + OracleContainer container = new OracleContainer(image) + .withDatabaseName((String) options.getRequiredValue(ConnectionFactoryOptions.DATABASE)); + if (Boolean.TRUE.equals(options.getValue(REUSABLE_OPTION))) { + container.withReuse(true); + } + return new OracleR2DBCDatabaseContainer(container); + } + + @Nullable + @Override + public ConnectionFactoryMetadata getMetadata(ConnectionFactoryOptions options) { + ConnectionFactoryOptions.Builder builder = options.mutate(); + if (!options.hasOption(ConnectionFactoryOptions.USER)) { + builder.option(ConnectionFactoryOptions.USER, OracleContainer.APP_USER); + } + if (!options.hasOption(ConnectionFactoryOptions.PASSWORD)) { + builder.option(ConnectionFactoryOptions.PASSWORD, OracleContainer.APP_USER_PASSWORD); + } + return R2DBCDatabaseContainerProvider.super.getMetadata(builder.build()); + } +} diff --git a/modules/oracle-xe/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider b/modules/oracle-xe/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider new file mode 100644 index 00000000000..cd1df0b4488 --- /dev/null +++ b/modules/oracle-xe/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.containers.OracleR2DBCDatabaseContainerProvider diff --git a/modules/oracle-xe/src/test/java/org/testcontainers/containers/jdbc/OracleJDBCDriverTest.java b/modules/oracle-xe/src/test/java/org/testcontainers/containers/jdbc/OracleJDBCDriverTest.java new file mode 100644 index 00000000000..4b672827fe6 --- /dev/null +++ b/modules/oracle-xe/src/test/java/org/testcontainers/containers/jdbc/OracleJDBCDriverTest.java @@ -0,0 +1,48 @@ +package org.testcontainers.containers.jdbc; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.apache.commons.dbutils.QueryRunner; +import org.apache.commons.dbutils.ResultSetHandler; +import org.junit.jupiter.api.Test; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +class OracleJDBCDriverTest { + + @Test + void testOracleWithNoSpecifiedVersion() throws SQLException { + performSimpleTest("jdbc:tc:oracle://hostname/databasename"); + } + + private void performSimpleTest(String jdbcUrl) throws SQLException { + HikariDataSource dataSource = getDataSource(jdbcUrl, 1); + new QueryRunner(dataSource) + .query( + "SELECT 1 FROM dual", + new ResultSetHandler() { + @Override + public Object handle(ResultSet rs) throws SQLException { + rs.next(); + int resultSetInt = rs.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + return true; + } + } + ); + dataSource.close(); + } + + private HikariDataSource getDataSource(String jdbcUrl, int poolSize) { + HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(jdbcUrl); + hikariConfig.setConnectionTestQuery("SELECT 1 FROM dual"); + hikariConfig.setMinimumIdle(1); + hikariConfig.setMaximumPoolSize(poolSize); + + return new HikariDataSource(hikariConfig); + } +} diff --git a/modules/oracle-xe/src/test/java/org/testcontainers/containers/r2dbc/OracleR2DBCDatabaseContainerTest.java b/modules/oracle-xe/src/test/java/org/testcontainers/containers/r2dbc/OracleR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..cca5f38ee12 --- /dev/null +++ b/modules/oracle-xe/src/test/java/org/testcontainers/containers/r2dbc/OracleR2DBCDatabaseContainerTest.java @@ -0,0 +1,30 @@ +package org.testcontainers.containers.r2dbc; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.containers.OracleContainer; +import org.testcontainers.containers.OracleR2DBCDatabaseContainer; +import org.testcontainers.r2dbc.AbstractR2DBCDatabaseContainerTest; + +public class OracleR2DBCDatabaseContainerTest extends AbstractR2DBCDatabaseContainerTest { + + @Override + protected OracleContainer createContainer() { + return new OracleContainer("gvenzl/oracle-xe:21-slim-faststart"); + } + + @Override + protected ConnectionFactoryOptions getOptions(OracleContainer container) { + ConnectionFactoryOptions options = OracleR2DBCDatabaseContainer.getOptions(container); + + return options; + } + + protected String createR2DBCUrl() { + return "r2dbc:tc:oracle:///db?TC_IMAGE_TAG=21-slim-faststart"; + } + + @Override + protected String query() { + return "SELECT %s from dual"; + } +} diff --git a/modules/oracle-xe/src/test/java/org/testcontainers/junit/SimpleOracleTest.java b/modules/oracle-xe/src/test/java/org/testcontainers/junit/SimpleOracleTest.java deleted file mode 100644 index 5c47d3e2f92..00000000000 --- a/modules/oracle-xe/src/test/java/org/testcontainers/junit/SimpleOracleTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.testcontainers.junit; - -import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; -import org.junit.Rule; -import org.junit.Test; -import org.testcontainers.containers.OracleContainer; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; - -/** - * @author gusohal - */ -public class SimpleOracleTest { - - @Rule - public OracleContainer oracle = new OracleContainer(); - - @Test - public void testSimple() throws SQLException { - HikariConfig hikariConfig = new HikariConfig(); - hikariConfig.setJdbcUrl(oracle.getJdbcUrl()); - hikariConfig.setUsername(oracle.getUsername()); - hikariConfig.setPassword(oracle.getPassword()); - - HikariDataSource ds = new HikariDataSource(hikariConfig); - Statement statement = ds.getConnection().createStatement(); - statement.execute("SELECT 1 FROM dual"); - ResultSet resultSet = statement.getResultSet(); - - resultSet.next(); - int resultSetInt = resultSet.getInt(1); - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } -} diff --git a/modules/oracle-xe/src/test/java/org/testcontainers/junit/oracle/SimpleOracleTest.java b/modules/oracle-xe/src/test/java/org/testcontainers/junit/oracle/SimpleOracleTest.java new file mode 100644 index 00000000000..17ee85944a7 --- /dev/null +++ b/modules/oracle-xe/src/test/java/org/testcontainers/junit/oracle/SimpleOracleTest.java @@ -0,0 +1,146 @@ +package org.testcontainers.junit.oracle; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.OracleContainer; +import org.testcontainers.db.AbstractContainerDatabaseTest; +import org.testcontainers.utility.DockerImageName; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +class SimpleOracleTest extends AbstractContainerDatabaseTest { + + public static final DockerImageName ORACLE_DOCKER_IMAGE_NAME = DockerImageName.parse( + "gvenzl/oracle-xe:21-slim-faststart" + ); + + private void runTest(OracleContainer container, String databaseName, String username, String password) + throws SQLException { + //Test config was honored + assertThat(container.getDatabaseName()).isEqualTo(databaseName); + assertThat(container.getUsername()).isEqualTo(username); + assertThat(container.getPassword()).isEqualTo(password); + + //Test we can get a connection + container.start(); + ResultSet resultSet = performQuery(container, "SELECT 1 FROM dual"); + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + } + + @Test + void testDefaultSettings() throws SQLException { + try ( // container { + OracleContainer oracle = new OracleContainer("gvenzl/oracle-xe:21-slim-faststart") + // } + ) { + runTest(oracle, "xepdb1", "test", "test"); + + // Match against the last '/' + String urlSuffix = oracle.getJdbcUrl().split("(\\/)(?!.*\\/)", 2)[1]; + assertThat(urlSuffix).isEqualTo("xepdb1"); + } + } + + @Test + void testPluggableDatabase() throws SQLException { + try (OracleContainer oracle = new OracleContainer(ORACLE_DOCKER_IMAGE_NAME).withDatabaseName("testDB")) { + runTest(oracle, "testDB", "test", "test"); + } + } + + @Test + void testPluggableDatabaseAndCustomUser() throws SQLException { + try ( + OracleContainer oracle = new OracleContainer("gvenzl/oracle-xe:21-slim-faststart") + .withDatabaseName("testDB") + .withUsername("testUser") + .withPassword("testPassword") + ) { + runTest(oracle, "testDB", "testUser", "testPassword"); + } + } + + @Test + void testCustomUser() throws SQLException { + try ( + OracleContainer oracle = new OracleContainer(ORACLE_DOCKER_IMAGE_NAME) + .withUsername("testUser") + .withPassword("testPassword") + ) { + runTest(oracle, "xepdb1", "testUser", "testPassword"); + } + } + + @Test + void testSID() throws SQLException { + try (OracleContainer oracle = new OracleContainer(ORACLE_DOCKER_IMAGE_NAME).usingSid();) { + runTest(oracle, "xepdb1", "system", "test"); + + // Match against the last ':' + String urlSuffix = oracle.getJdbcUrl().split("(\\:)(?!.*\\:)", 2)[1]; + assertThat(urlSuffix).isEqualTo("xe"); + } + } + + @Test + void testSIDAndCustomPassword() throws SQLException { + try ( + OracleContainer oracle = new OracleContainer(ORACLE_DOCKER_IMAGE_NAME) + .usingSid() + .withPassword("testPassword"); + ) { + runTest(oracle, "xepdb1", "system", "testPassword"); + } + } + + @Test + void testErrorPaths() throws SQLException { + try (OracleContainer oracle = new OracleContainer(ORACLE_DOCKER_IMAGE_NAME)) { + try { + oracle.withDatabaseName("XEPDB1"); + fail("Should not have been able to set database name to xepdb1."); + } catch (IllegalArgumentException e) { + //expected + } + + try { + oracle.withDatabaseName(""); + fail("Should not have been able to set database name to nothing."); + } catch (IllegalArgumentException e) { + //expected + } + + try { + oracle.withUsername("SYSTEM"); + fail("Should not have been able to set username to system."); + } catch (IllegalArgumentException e) { + //expected + } + + try { + oracle.withUsername("SYS"); + fail("Should not have been able to set username to sys."); + } catch (IllegalArgumentException e) { + //expected + } + + try { + oracle.withUsername(""); + fail("Should not have been able to set username to nothing."); + } catch (IllegalArgumentException e) { + //expected + } + + try { + oracle.withPassword(""); + fail("Should not have been able to set password to nothing."); + } catch (IllegalArgumentException e) { + //expected + } + } + } +} diff --git a/modules/oracle-xe/src/test/resources/logback-test.xml b/modules/oracle-xe/src/test/resources/logback-test.xml index ed0e5b2659e..83ef7a1a3ef 100644 --- a/modules/oracle-xe/src/test/resources/logback-test.xml +++ b/modules/oracle-xe/src/test/resources/logback-test.xml @@ -8,22 +8,9 @@ - + - - - - - - - - - - - - PROFILER - DENY - - \ No newline at end of file + + diff --git a/modules/orientdb/build.gradle b/modules/orientdb/build.gradle new file mode 100644 index 00000000000..6d691b407ce --- /dev/null +++ b/modules/orientdb/build.gradle @@ -0,0 +1,10 @@ +description = "Testcontainers :: Orientdb" + +dependencies { + api project(":testcontainers") + + api "com.orientechnologies:orientdb-client:3.2.53" + + testImplementation 'org.apache.tinkerpop:gremlin-driver:3.8.1' + testImplementation "com.orientechnologies:orientdb-gremlin:3.2.53" +} diff --git a/modules/orientdb/src/main/java/org/testcontainers/containers/OrientDBContainer.java b/modules/orientdb/src/main/java/org/testcontainers/containers/OrientDBContainer.java new file mode 100644 index 00000000000..e21472dd16a --- /dev/null +++ b/modules/orientdb/src/main/java/org/testcontainers/containers/OrientDBContainer.java @@ -0,0 +1,194 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.orientechnologies.orient.core.db.ODatabaseSession; +import com.orientechnologies.orient.core.db.ODatabaseType; +import com.orientechnologies.orient.core.db.OrientDB; +import com.orientechnologies.orient.core.db.OrientDBConfig; +import lombok.NonNull; +import org.apache.commons.io.IOUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Optional; + +/** + * Testcontainers implementation for OrientDB. + *

+ * Supported image: {@code orientdb} + *

+ * Exposed ports: + *

    + *
  • Database: 2424
  • + *
  • Studio: 2480
  • + *
+ * + * @deprecated use {@link org.testcontainers.orientdb.OrientDBContainer} instead. + */ +@Deprecated +public class OrientDBContainer extends GenericContainer { + + private static final Logger LOGGER = LoggerFactory.getLogger(OrientDBContainer.class); + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("orientdb"); + + private static final String DEFAULT_TAG = "3.0.24-tp3"; + + private static final String DEFAULT_USERNAME = "admin"; + + private static final String DEFAULT_PASSWORD = "admin"; + + private static final String DEFAULT_SERVER_PASSWORD = "root"; + + private static final String DEFAULT_DATABASE_NAME = "testcontainers"; + + private static final int DEFAULT_BINARY_PORT = 2424; + + private static final int DEFAULT_HTTP_PORT = 2480; + + private String databaseName; + + private String serverPassword; + + private Optional scriptPath = Optional.empty(); + + private OrientDB orientDB; + + private ODatabaseSession session; + + /** + * @deprecated use {@link #OrientDBContainer(DockerImageName)} instead + */ + @Deprecated + public OrientDBContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + public OrientDBContainer(@NonNull String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public OrientDBContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + serverPassword = DEFAULT_SERVER_PASSWORD; + databaseName = DEFAULT_DATABASE_NAME; + + waitStrategy = Wait.forLogMessage(".*OrientDB Studio available.*", 1); + + addExposedPorts(DEFAULT_BINARY_PORT, DEFAULT_HTTP_PORT); + } + + @Override + protected void configure() { + addEnv("ORIENTDB_ROOT_PASSWORD", serverPassword); + } + + public String getDatabaseName() { + return databaseName; + } + + public String getTestQueryString() { + return "SELECT FROM V"; + } + + public OrientDBContainer withDatabaseName(final String databaseName) { + this.databaseName = databaseName; + return self(); + } + + public OrientDBContainer withServerPassword(final String serverPassword) { + this.serverPassword = serverPassword; + return self(); + } + + public OrientDBContainer withScriptPath(String scriptPath) { + this.scriptPath = Optional.of(scriptPath); + return self(); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + orientDB = new OrientDB(getServerUrl(), "root", serverPassword, OrientDBConfig.defaultConfig()); + } + + @Deprecated + public OrientDB getOrientDB() { + return orientDB; + } + + public String getServerUrl() { + return "remote:" + getHost() + ":" + getMappedPort(2424); + } + + public String getDbUrl() { + return getServerUrl() + "/" + databaseName; + } + + @Deprecated + public ODatabaseSession getSession() { + return getSession(DEFAULT_USERNAME, DEFAULT_PASSWORD); + } + + @Deprecated + public synchronized ODatabaseSession getSession(String username, String password) { + String orientdbVersion = Arrays + .stream(this.getContainerInfo().getConfig().getEnv()) + .filter(env -> env.startsWith("ORIENTDB_VERSION")) + .map(env -> env.split("=")[1]) + .findFirst() + .orElseThrow(() -> new IllegalStateException("no required env var")); + boolean isGreaterThan32 = new ComparableVersion(orientdbVersion).isGreaterThanOrEqualTo("3.2.0"); + if (isGreaterThan32) { + String script = String.format( + "CREATE DATABASE %s plocal users(%s identified by '%s' role admin)", + databaseName, + username, + password + ); + if (!orientDB.exists(databaseName)) { + orientDB.execute(script); + } + } else { + orientDB.createIfNotExists(databaseName, ODatabaseType.PLOCAL); + } + if (session == null) { + session = orientDB.open(databaseName, username, password); + + scriptPath.ifPresent(path -> loadScript(path, session)); + } + return session; + } + + @Deprecated + private void loadScript(String path, ODatabaseSession session) { + try { + URL resource = getClass().getClassLoader().getResource(path); + + if (resource == null) { + LOGGER.warn("Could not load classpath init script: {}", scriptPath); + throw new RuntimeException( + "Could not load classpath init script: " + scriptPath + ". Resource not found." + ); + } + + String script = IOUtils.toString(resource, StandardCharsets.UTF_8); + + session.execute("sql", script); + } catch (IOException e) { + LOGGER.warn("Could not load classpath init script: {}", scriptPath); + throw new RuntimeException("Could not load classpath init script: " + scriptPath, e); + } catch (UnsupportedOperationException e) { + LOGGER.error("Error while executing init script: {}", scriptPath, e); + throw new RuntimeException("Error while executing init script: " + scriptPath, e); + } + } +} diff --git a/modules/orientdb/src/main/java/org/testcontainers/orientdb/OrientDBContainer.java b/modules/orientdb/src/main/java/org/testcontainers/orientdb/OrientDBContainer.java new file mode 100644 index 00000000000..753d32951e7 --- /dev/null +++ b/modules/orientdb/src/main/java/org/testcontainers/orientdb/OrientDBContainer.java @@ -0,0 +1,140 @@ +package org.testcontainers.orientdb; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import lombok.NonNull; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; + +/** + * Testcontainers implementation for OrientDB. + *

+ * Supported image: {@code orientdb} + *

+ * Exposed ports: + *

    + *
  • Database: 2424
  • + *
  • Studio: 2480
  • + *
+ */ +public class OrientDBContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("orientdb"); + + private static final String DEFAULT_USERNAME = "admin"; + + private static final String DEFAULT_PASSWORD = "admin"; + + private static final String DEFAULT_SERVER_USER = "root"; + + private static final String DEFAULT_SERVER_PASSWORD = "root"; + + private static final String DEFAULT_DATABASE_NAME = "testcontainers"; + + private static final int DEFAULT_BINARY_PORT = 2424; + + private static final int DEFAULT_HTTP_PORT = 2480; + + private String databaseName; + + private String serverPassword; + + private Transferable scriptPath; + + public OrientDBContainer(@NonNull String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public OrientDBContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + this.serverPassword = DEFAULT_SERVER_PASSWORD; + this.databaseName = DEFAULT_DATABASE_NAME; + + waitingFor(Wait.forLogMessage(".*OrientDB Studio available.*", 1)); + addExposedPorts(DEFAULT_BINARY_PORT, DEFAULT_HTTP_PORT); + } + + @Override + protected void configure() { + addEnv("ORIENTDB_ROOT_PASSWORD", serverPassword); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + try { + String createDb = String.format( + "CREATE DATABASE remote:localhost/%s %s %s plocal; CONNECT remote:localhost/%s %s %s; CREATE USER %s IDENTIFIED BY %s ROLE admin;", + this.databaseName, + DEFAULT_SERVER_USER, + this.serverPassword, + this.databaseName, + DEFAULT_SERVER_USER, + this.serverPassword, + DEFAULT_USERNAME, + DEFAULT_PASSWORD + ); + execInContainer("/orientdb/bin/console.sh", createDb); + + if (this.scriptPath != null) { + copyFileToContainer(this.scriptPath, "/opt/testcontainers/script.osql"); + String loadScript = String.format( + "CONNECT remote:localhost/%s %s %s; LOAD SCRIPT /opt/testcontainers/script.osql", + this.databaseName, + DEFAULT_SERVER_USER, + this.serverPassword + ); + execInContainer("/orientdb/bin/console.sh", loadScript); + } + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + public String getDatabaseName() { + return databaseName; + } + + public OrientDBContainer withDatabaseName(final String databaseName) { + this.databaseName = databaseName; + return self(); + } + + public OrientDBContainer withServerPassword(final String serverPassword) { + this.serverPassword = serverPassword; + return self(); + } + + public OrientDBContainer withScriptPath(Transferable scriptPath) { + this.scriptPath = scriptPath; + return self(); + } + + public String getServerUrl() { + return "remote:" + getHost() + ":" + getMappedPort(2424); + } + + public String getDbUrl() { + return getServerUrl() + "/" + this.databaseName; + } + + public String getServerUser() { + return DEFAULT_SERVER_USER; + } + + public String getServerPassword() { + return this.serverPassword; + } + + public String getUsername() { + return DEFAULT_USERNAME; + } + + public String getPassword() { + return DEFAULT_PASSWORD; + } +} diff --git a/modules/orientdb/src/test/java/org/testcontainers/orientdb/OrientDBContainerTest.java b/modules/orientdb/src/test/java/org/testcontainers/orientdb/OrientDBContainerTest.java new file mode 100644 index 00000000000..2952b70f216 --- /dev/null +++ b/modules/orientdb/src/test/java/org/testcontainers/orientdb/OrientDBContainerTest.java @@ -0,0 +1,102 @@ +package org.testcontainers.orientdb; + +import com.orientechnologies.orient.core.db.ODatabaseSession; +import com.orientechnologies.orient.core.db.OrientDB; +import com.orientechnologies.orient.core.db.OrientDBConfig; +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrientDBContainerTest { + + private static final DockerImageName ORIENTDB_IMAGE = DockerImageName.parse("orientdb:3.2.0-tp3"); + + @Test + void shouldInitializeWithCommands() { + try ( // container { + OrientDBContainer orientdb = new OrientDBContainer("orientdb:3.2.0-tp3") + // } + ) { + orientdb.start(); + + OrientDB orientDB = new OrientDB( + orientdb.getServerUrl(), + orientdb.getServerUser(), + orientdb.getServerPassword(), + OrientDBConfig.defaultConfig() + ); + ODatabaseSession session = orientDB.open( + orientdb.getDatabaseName(), + orientdb.getUsername(), + orientdb.getPassword() + ); + + session.command("CREATE CLASS Person EXTENDS V"); + session.command("INSERT INTO Person set name='john'"); + session.command("INSERT INTO Person set name='jane'"); + + assertThat(session.query("SELECT FROM Person").stream()).hasSize(2); + } + } + + @Test + void shouldQueryWithGremlin() { + try ( + OrientDBContainer orientdb = new OrientDBContainer(ORIENTDB_IMAGE) + .withCopyFileToContainer( + MountableFile.forClasspathResource("orientdb-server-config.xml"), + "/orientdb/config/orientdb-server-config.xml" + ) + ) { + orientdb.start(); + + OrientDB orientDB = new OrientDB( + orientdb.getServerUrl(), + orientdb.getServerUser(), + orientdb.getServerPassword(), + OrientDBConfig.defaultConfig() + ); + ODatabaseSession session = orientDB.open( + orientdb.getDatabaseName(), + orientdb.getUsername(), + orientdb.getPassword() + ); + + session.command("CREATE CLASS Person EXTENDS V"); + session.command("INSERT INTO Person set name='john'"); + session.command("INSERT INTO Person set name='jane'"); + + assertThat(session.execute("gremlin", "g.V().hasLabel('Person')").stream()).hasSize(2); + } + } + + @Test + void shouldInitializeDatabaseFromScript() { + try ( + OrientDBContainer orientdb = new OrientDBContainer(ORIENTDB_IMAGE) + .withScriptPath(MountableFile.forClasspathResource("initscript.osql")) + .withDatabaseName("persons") + ) { + orientdb.start(); + + assertThat(orientdb.getDbUrl()) + .isEqualTo("remote:" + orientdb.getHost() + ":" + orientdb.getMappedPort(2424) + "/persons"); + + OrientDB orientDB = new OrientDB( + orientdb.getServerUrl(), + orientdb.getServerUser(), + orientdb.getServerPassword(), + OrientDBConfig.defaultConfig() + ); + ODatabaseSession session = orientDB.open( + orientdb.getDatabaseName(), + orientdb.getUsername(), + orientdb.getPassword() + ); + + assertThat(session.query("SELECT FROM Person").stream()).hasSize(4); + } + } +} diff --git a/modules/orientdb/src/test/resources/initscript.osql b/modules/orientdb/src/test/resources/initscript.osql new file mode 100644 index 00000000000..e1ba22bbb3e --- /dev/null +++ b/modules/orientdb/src/test/resources/initscript.osql @@ -0,0 +1,6 @@ +CREATE CLASS Person EXTENDS V; + +INSERT INTO Person set name="john"; +INSERT INTO Person set name="paul"; +INSERT INTO Person set name="luke"; +INSERT INTO Person set name="albert"; diff --git a/modules/orientdb/src/test/resources/logback-test.xml b/modules/orientdb/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..d91138d8b74 --- /dev/null +++ b/modules/orientdb/src/test/resources/logback-test.xml @@ -0,0 +1,18 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + + + diff --git a/modules/orientdb/src/test/resources/orientdb-server-config.xml b/modules/orientdb/src/test/resources/orientdb-server-config.xml new file mode 100644 index 00000000000..ac6a4900b28 --- /dev/null +++ b/modules/orientdb/src/test/resources/orientdb-server-config.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/pinecone/build.gradle b/modules/pinecone/build.gradle new file mode 100644 index 00000000000..ad46d3ce9d9 --- /dev/null +++ b/modules/pinecone/build.gradle @@ -0,0 +1,7 @@ +description = "Testcontainers :: Pinecone" + +dependencies { + api project(':testcontainers') + + testImplementation 'io.pinecone:pinecone-client:3.1.0' +} diff --git a/modules/pinecone/src/main/java/org/testcontainers/pinecone/PineconeLocalContainer.java b/modules/pinecone/src/main/java/org/testcontainers/pinecone/PineconeLocalContainer.java new file mode 100644 index 00000000000..56c846872de --- /dev/null +++ b/modules/pinecone/src/main/java/org/testcontainers/pinecone/PineconeLocalContainer.java @@ -0,0 +1,34 @@ +package org.testcontainers.pinecone; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for Pinecone. + *

+ * Exposed port: 5080 + */ +public class PineconeLocalContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse( + "ghcr.io/pinecone-io/pinecone-local" + ); + + private static final int PORT = 5080; + + public PineconeLocalContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public PineconeLocalContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + withEnv("PORT", String.valueOf(5080)); + withExposedPorts(5080); + } + + public String getEndpoint() { + return "http://" + getHost() + ":" + getMappedPort(PORT); + } +} diff --git a/modules/pinecone/src/test/java/org/testcontainers/pinecone/PineconeLocalContainerTest.java b/modules/pinecone/src/test/java/org/testcontainers/pinecone/PineconeLocalContainerTest.java new file mode 100644 index 00000000000..6303e069423 --- /dev/null +++ b/modules/pinecone/src/test/java/org/testcontainers/pinecone/PineconeLocalContainerTest.java @@ -0,0 +1,33 @@ +package org.testcontainers.pinecone; + +import io.pinecone.clients.Pinecone; +import org.junit.jupiter.api.Test; +import org.openapitools.db_control.client.model.DeletionProtection; +import org.openapitools.db_control.client.model.IndexModel; + +import static org.assertj.core.api.Assertions.assertThat; + +class PineconeLocalContainerTest { + + @Test + void testSimple() { + try ( // container { + PineconeLocalContainer container = new PineconeLocalContainer("ghcr.io/pinecone-io/pinecone-local:v0.7.0") + // } + ) { + container.start(); + + // client { + Pinecone pinecone = new Pinecone.Builder("pclocal") + .withHost(container.getEndpoint()) + .withTlsEnabled(false) + .build(); + // } + + String indexName = "example-index"; + pinecone.createServerlessIndex(indexName, "cosine", 2, "aws", "us-east-1", DeletionProtection.DISABLED); + IndexModel indexModel = pinecone.describeIndex(indexName); + assertThat(indexModel.getDeletionProtection()).isEqualTo(DeletionProtection.DISABLED); + } + } +} diff --git a/modules/pinecone/src/test/resources/logback-test.xml b/modules/pinecone/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/pinecone/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/postgresql/build.gradle b/modules/postgresql/build.gradle new file mode 100644 index 00000000000..db7382f34d3 --- /dev/null +++ b/modules/postgresql/build.gradle @@ -0,0 +1,16 @@ +description = "Testcontainers :: JDBC :: PostgreSQL" + +dependencies { + api project(':testcontainers-jdbc') + + compileOnly project(':testcontainers-r2dbc') + compileOnly 'io.r2dbc:r2dbc-postgresql:0.8.13.RELEASE' + + testImplementation project(':testcontainers-jdbc-test') + testRuntimeOnly 'org.postgresql:postgresql:42.7.12' + + testImplementation testFixtures(project(':testcontainers-r2dbc')) + testRuntimeOnly 'io.r2dbc:r2dbc-postgresql:0.8.13.RELEASE' + + compileOnly 'org.jetbrains:annotations:26.1.0' +} diff --git a/modules/postgresql/pom.xml b/modules/postgresql/pom.xml deleted file mode 100644 index 66d2930b7a5..00000000000 --- a/modules/postgresql/pom.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - 4.0.0 - - - org.testcontainers - testcontainers-parent - 1.2.1-SNAPSHOT - ../../pom.xml - - - postgresql - TestContainers :: JDBC :: PostgreSQL - - - - ${project.groupId} - testcontainers - ${project.version} - - - ${project.groupId} - jdbc - ${project.version} - - - - - org.postgresql - postgresql - 9.3-1101-jdbc41 - test - - - - - com.zaxxer - HikariCP-java6 - 2.3.8 - test - - - commons-dbutils - commons-dbutils - 1.6 - test - - - \ No newline at end of file diff --git a/modules/postgresql/src/main/java/org/testcontainers/containers/PgVectorContainerProvider.java b/modules/postgresql/src/main/java/org/testcontainers/containers/PgVectorContainerProvider.java new file mode 100644 index 00000000000..29f6c3bf935 --- /dev/null +++ b/modules/postgresql/src/main/java/org/testcontainers/containers/PgVectorContainerProvider.java @@ -0,0 +1,42 @@ +package org.testcontainers.containers; + +import org.testcontainers.jdbc.ConnectionUrl; +import org.testcontainers.utility.DockerImageName; + +/** + * Factory for PgVector containers. + * + * @see https://github.com/pgvector/pgvector + */ +public class PgVectorContainerProvider extends JdbcDatabaseContainerProvider { + + private static final String NAME = "pgvector"; + + private static final String DEFAULT_TAG = "pg16"; + + private static final DockerImageName DEFAULT_IMAGE = DockerImageName.parse("pgvector/pgvector"); + + public static final String USER_PARAM = "user"; + + public static final String PASSWORD_PARAM = "password"; + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(NAME); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(DEFAULT_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + return new PostgreSQLContainer(DEFAULT_IMAGE.withTag(tag)); + } + + @Override + public JdbcDatabaseContainer newInstance(ConnectionUrl connectionUrl) { + return newInstanceFromConnectionUrl(connectionUrl, USER_PARAM, PASSWORD_PARAM); + } +} diff --git a/modules/postgresql/src/main/java/org/testcontainers/containers/PostgisContainerProvider.java b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgisContainerProvider.java new file mode 100644 index 00000000000..0c30ebbb246 --- /dev/null +++ b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgisContainerProvider.java @@ -0,0 +1,42 @@ +package org.testcontainers.containers; + +import org.testcontainers.jdbc.ConnectionUrl; +import org.testcontainers.utility.DockerImageName; + +/** + * Factory for PostGIS containers, which are a special flavour of PostgreSQL. + */ +public class PostgisContainerProvider extends JdbcDatabaseContainerProvider { + + private static final String NAME = "postgis"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName + .parse("postgis/postgis") + .asCompatibleSubstituteFor("postgres"); + + private static final String DEFAULT_TAG = "12-3.0"; + + public static final String USER_PARAM = "user"; + + public static final String PASSWORD_PARAM = "password"; + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(NAME); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(DEFAULT_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + return new PostgreSQLContainer<>(DEFAULT_IMAGE_NAME.withTag(tag)); + } + + @Override + public JdbcDatabaseContainer newInstance(ConnectionUrl connectionUrl) { + return newInstanceFromConnectionUrl(connectionUrl, USER_PARAM, PASSWORD_PARAM); + } +} diff --git a/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLContainer.java b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLContainer.java index 725f73d639c..4824654957f 100644 --- a/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLContainer.java +++ b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLContainer.java @@ -1,37 +1,93 @@ package org.testcontainers.containers; +import org.jetbrains.annotations.NotNull; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Set; + /** - * @author richardnorth + * Testcontainers implementation for PostgreSQL. + *

+ * Supported images: {@code postgres}, {@code pgvector/pgvector} + *

+ * Exposed ports: 5432 + * + * @deprecated use {@link org.testcontainers.postgresql.PostgreSQLContainer} instead. */ +@Deprecated public class PostgreSQLContainer> extends JdbcDatabaseContainer { + public static final String NAME = "postgresql"; + public static final String IMAGE = "postgres"; + + public static final String DEFAULT_TAG = "9.6.12"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("postgres"); + + private static final DockerImageName PGVECTOR_IMAGE_NAME = DockerImageName.parse("pgvector/pgvector"); + public static final Integer POSTGRESQL_PORT = 5432; + + static final String DEFAULT_USER = "test"; + + static final String DEFAULT_PASSWORD = "test"; + private String databaseName = "test"; + private String username = "test"; + private String password = "test"; + private static final String FSYNC_OFF_OPTION = "fsync=off"; + + /** + * @deprecated use {@link #PostgreSQLContainer(DockerImageName)} or {@link #PostgreSQLContainer(String)} instead + */ + @Deprecated public PostgreSQLContainer() { - this(IMAGE + ":latest"); + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); } public PostgreSQLContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public PostgreSQLContainer(final DockerImageName dockerImageName) { super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, PGVECTOR_IMAGE_NAME); + + this.waitStrategy = + new LogMessageWaitStrategy() + .withRegEx(".*database system is ready to accept connections.*\\s") + .withTimes(2) + .withStartupTimeout(Duration.of(60, ChronoUnit.SECONDS)); + this.setCommand("postgres", "-c", FSYNC_OFF_OPTION); + + addExposedPort(POSTGRESQL_PORT); } + /** + * @return the ports on which to check if the container is ready + * @deprecated use {@link #getLivenessCheckPortNumbers()} instead + */ + @NotNull @Override - protected Integer getLivenessCheckPort() { - return getMappedPort(POSTGRESQL_PORT); + @Deprecated + protected Set getLivenessCheckPorts() { + return super.getLivenessCheckPorts(); } @Override protected void configure() { - - addExposedPort(POSTGRESQL_PORT); + // Disable Postgres driver use of java.util.logging to reduce noise at startup time + withUrlParam("loggerLevel", "OFF"); addEnv("POSTGRES_DB", databaseName); addEnv("POSTGRES_USER", username); addEnv("POSTGRES_PASSWORD", password); - setCommand("postgres"); } @Override @@ -41,7 +97,21 @@ public String getDriverClassName() { @Override public String getJdbcUrl() { - return "jdbc:postgresql://" + getContainerIpAddress() + ":" + getMappedPort(POSTGRESQL_PORT) + "/" + databaseName; + String additionalUrlParams = constructUrlParameters("?", "&"); + return ( + "jdbc:postgresql://" + + getHost() + + ":" + + getMappedPort(POSTGRESQL_PORT) + + "/" + + databaseName + + additionalUrlParams + ); + } + + @Override + public String getDatabaseName() { + return databaseName; } @Override @@ -59,18 +129,26 @@ public String getTestQueryString() { return "SELECT 1"; } + @Override public SELF withDatabaseName(final String databaseName) { this.databaseName = databaseName; return self(); } + @Override public SELF withUsername(final String username) { this.username = username; return self(); } + @Override public SELF withPassword(final String password) { this.password = password; return self(); } + + @Override + protected void waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this); + } } diff --git a/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLContainerProvider.java b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLContainerProvider.java index fae051b6d97..de98885510c 100644 --- a/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLContainerProvider.java +++ b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLContainerProvider.java @@ -1,16 +1,34 @@ package org.testcontainers.containers; +import org.testcontainers.jdbc.ConnectionUrl; +import org.testcontainers.utility.DockerImageName; + /** * Factory for PostgreSQL containers. */ public class PostgreSQLContainerProvider extends JdbcDatabaseContainerProvider { + + public static final String USER_PARAM = "user"; + + public static final String PASSWORD_PARAM = "password"; + @Override public boolean supports(String databaseType) { return databaseType.equals(PostgreSQLContainer.NAME); } + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(PostgreSQLContainer.DEFAULT_TAG); + } + @Override public JdbcDatabaseContainer newInstance(String tag) { - return new PostgreSQLContainer(PostgreSQLContainer.IMAGE + ":" + tag); + return new PostgreSQLContainer(DockerImageName.parse(PostgreSQLContainer.IMAGE).withTag(tag)); + } + + @Override + public JdbcDatabaseContainer newInstance(ConnectionUrl connectionUrl) { + return newInstanceFromConnectionUrl(connectionUrl, USER_PARAM, PASSWORD_PARAM); } } diff --git a/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainer.java b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainer.java new file mode 100644 index 00000000000..d2b933694c7 --- /dev/null +++ b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainer.java @@ -0,0 +1,35 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; + +@RequiredArgsConstructor +public final class PostgreSQLR2DBCDatabaseContainer implements R2DBCDatabaseContainer { + + @Delegate(types = Startable.class) + private final PostgreSQLContainer container; + + public static ConnectionFactoryOptions getOptions(PostgreSQLContainer container) { + ConnectionFactoryOptions options = ConnectionFactoryOptions + .builder() + .option(ConnectionFactoryOptions.DRIVER, PostgreSQLR2DBCDatabaseContainerProvider.DRIVER) + .build(); + + return new PostgreSQLR2DBCDatabaseContainer(container).configure(options); + } + + @Override + public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { + return options + .mutate() + .option(ConnectionFactoryOptions.HOST, container.getHost()) + .option(ConnectionFactoryOptions.PORT, container.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT)) + .option(ConnectionFactoryOptions.DATABASE, container.getDatabaseName()) + .option(ConnectionFactoryOptions.USER, container.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, container.getPassword()) + .build(); + } +} diff --git a/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerProvider.java b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerProvider.java new file mode 100644 index 00000000000..f1bdf67ee5c --- /dev/null +++ b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerProvider.java @@ -0,0 +1,44 @@ +package org.testcontainers.containers; + +import io.r2dbc.postgresql.PostgresqlConnectionFactoryProvider; +import io.r2dbc.spi.ConnectionFactoryMetadata; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; +import org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider; + +import javax.annotation.Nullable; + +public final class PostgreSQLR2DBCDatabaseContainerProvider implements R2DBCDatabaseContainerProvider { + + static final String DRIVER = PostgresqlConnectionFactoryProvider.POSTGRESQL_DRIVER; + + @Override + public boolean supports(ConnectionFactoryOptions options) { + return DRIVER.equals(options.getRequiredValue(ConnectionFactoryOptions.DRIVER)); + } + + @Override + public R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options) { + String image = PostgreSQLContainer.IMAGE + ":" + options.getRequiredValue(IMAGE_TAG_OPTION); + PostgreSQLContainer container = new PostgreSQLContainer<>(image) + .withDatabaseName((String) options.getRequiredValue(ConnectionFactoryOptions.DATABASE)); + + if (Boolean.TRUE.equals(options.getValue(REUSABLE_OPTION))) { + container.withReuse(true); + } + return new PostgreSQLR2DBCDatabaseContainer(container); + } + + @Nullable + @Override + public ConnectionFactoryMetadata getMetadata(ConnectionFactoryOptions options) { + ConnectionFactoryOptions.Builder builder = options.mutate(); + if (!options.hasOption(ConnectionFactoryOptions.USER)) { + builder.option(ConnectionFactoryOptions.USER, PostgreSQLContainer.DEFAULT_USER); + } + if (!options.hasOption(ConnectionFactoryOptions.PASSWORD)) { + builder.option(ConnectionFactoryOptions.PASSWORD, PostgreSQLContainer.DEFAULT_PASSWORD); + } + return R2DBCDatabaseContainerProvider.super.getMetadata(builder.build()); + } +} diff --git a/modules/postgresql/src/main/java/org/testcontainers/containers/TimescaleDBContainerProvider.java b/modules/postgresql/src/main/java/org/testcontainers/containers/TimescaleDBContainerProvider.java new file mode 100644 index 00000000000..86fcca2c039 --- /dev/null +++ b/modules/postgresql/src/main/java/org/testcontainers/containers/TimescaleDBContainerProvider.java @@ -0,0 +1,44 @@ +package org.testcontainers.containers; + +import org.testcontainers.jdbc.ConnectionUrl; +import org.testcontainers.utility.DockerImageName; + +/** + * Factory for TimescaleDB containers, which are a special flavour of PostgreSQL. + * + * @see https://docs.timescale.com/latest/introduction + */ +public class TimescaleDBContainerProvider extends JdbcDatabaseContainerProvider { + + private static final String NAME = "timescaledb"; + + private static final String DEFAULT_TAG = "2.1.0-pg11"; + + private static final DockerImageName DEFAULT_IMAGE = DockerImageName + .parse("timescale/timescaledb") + .asCompatibleSubstituteFor("postgres"); + + public static final String USER_PARAM = "user"; + + public static final String PASSWORD_PARAM = "password"; + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(NAME); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(DEFAULT_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + return new PostgreSQLContainer(DEFAULT_IMAGE.withTag(tag)); + } + + @Override + public JdbcDatabaseContainer newInstance(ConnectionUrl connectionUrl) { + return newInstanceFromConnectionUrl(connectionUrl, USER_PARAM, PASSWORD_PARAM); + } +} diff --git a/modules/postgresql/src/main/java/org/testcontainers/postgresql/PostgreSQLContainer.java b/modules/postgresql/src/main/java/org/testcontainers/postgresql/PostgreSQLContainer.java new file mode 100644 index 00000000000..27521487a23 --- /dev/null +++ b/modules/postgresql/src/main/java/org/testcontainers/postgresql/PostgreSQLContainer.java @@ -0,0 +1,144 @@ +package org.testcontainers.postgresql; + +import org.jetbrains.annotations.NotNull; +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Set; + +/** + * Testcontainers implementation for PostgreSQL. + *

+ * Supported images: {@code postgres}, {@code pgvector/pgvector} + *

+ * Exposed ports: 5432 + */ +public class PostgreSQLContainer extends JdbcDatabaseContainer { + + public static final String NAME = "postgresql"; + + public static final String IMAGE = "postgres"; + + public static final String DEFAULT_TAG = "9.6.12"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("postgres"); + + private static final DockerImageName PGVECTOR_IMAGE_NAME = DockerImageName.parse("pgvector/pgvector"); + + public static final Integer POSTGRESQL_PORT = 5432; + + static final String DEFAULT_USER = "test"; + + static final String DEFAULT_PASSWORD = "test"; + + private String databaseName = "test"; + + private String username = "test"; + + private String password = "test"; + + private static final String FSYNC_OFF_OPTION = "fsync=off"; + + public PostgreSQLContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public PostgreSQLContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, PGVECTOR_IMAGE_NAME); + + waitingFor( + Wait + .forLogMessage(".*database system is ready to accept connections.*\\s", 2) + .withStartupTimeout(Duration.of(60, ChronoUnit.SECONDS)) + ); + setCommand("postgres", "-c", FSYNC_OFF_OPTION); + + addExposedPort(POSTGRESQL_PORT); + } + + /** + * @return the ports on which to check if the container is ready + * @deprecated use {@link #getLivenessCheckPortNumbers()} instead + */ + @NotNull + @Override + @Deprecated + protected Set getLivenessCheckPorts() { + return super.getLivenessCheckPorts(); + } + + @Override + protected void configure() { + // Disable Postgres driver use of java.util.logging to reduce noise at startup time + withUrlParam("loggerLevel", "OFF"); + addEnv("POSTGRES_DB", databaseName); + addEnv("POSTGRES_USER", username); + addEnv("POSTGRES_PASSWORD", password); + } + + @Override + public String getDriverClassName() { + return "org.postgresql.Driver"; + } + + @Override + public String getJdbcUrl() { + String additionalUrlParams = constructUrlParameters("?", "&"); + return ( + "jdbc:postgresql://" + + getHost() + + ":" + + getMappedPort(POSTGRESQL_PORT) + + "/" + + databaseName + + additionalUrlParams + ); + } + + @Override + public String getDatabaseName() { + return databaseName; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getTestQueryString() { + return "SELECT 1"; + } + + @Override + public PostgreSQLContainer withDatabaseName(final String databaseName) { + this.databaseName = databaseName; + return self(); + } + + @Override + public PostgreSQLContainer withUsername(final String username) { + this.username = username; + return self(); + } + + @Override + public PostgreSQLContainer withPassword(final String password) { + this.password = password; + return self(); + } + + @Override + protected void waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this); + } +} diff --git a/modules/postgresql/src/main/java/org/testcontainers/postgresql/PostgreSQLR2DBCDatabaseContainer.java b/modules/postgresql/src/main/java/org/testcontainers/postgresql/PostgreSQLR2DBCDatabaseContainer.java new file mode 100644 index 00000000000..d99d638b100 --- /dev/null +++ b/modules/postgresql/src/main/java/org/testcontainers/postgresql/PostgreSQLR2DBCDatabaseContainer.java @@ -0,0 +1,58 @@ +package org.testcontainers.postgresql; + +import io.r2dbc.postgresql.PostgresqlConnectionFactoryProvider; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; + +import java.util.Set; + +public final class PostgreSQLR2DBCDatabaseContainer implements R2DBCDatabaseContainer { + + private final PostgreSQLContainer container; + + public PostgreSQLR2DBCDatabaseContainer(PostgreSQLContainer container) { + this.container = container; + } + + public static ConnectionFactoryOptions getOptions(PostgreSQLContainer container) { + ConnectionFactoryOptions options = ConnectionFactoryOptions + .builder() + .option(ConnectionFactoryOptions.DRIVER, PostgresqlConnectionFactoryProvider.POSTGRESQL_DRIVER) + .build(); + + return new PostgreSQLR2DBCDatabaseContainer(container).configure(options); + } + + @Override + public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { + return options + .mutate() + .option(ConnectionFactoryOptions.HOST, container.getHost()) + .option(ConnectionFactoryOptions.PORT, container.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT)) + .option(ConnectionFactoryOptions.DATABASE, container.getDatabaseName()) + .option(ConnectionFactoryOptions.USER, container.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, container.getPassword()) + .build(); + } + + @Override + public Set getDependencies() { + return this.container.getDependencies(); + } + + @Override + public void start() { + this.container.start(); + } + + @Override + public void stop() { + this.container.stop(); + } + + @Override + public void close() { + this.container.close(); + } +} diff --git a/modules/postgresql/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/postgresql/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider index 4b07faac03e..33429fa8b1e 100644 --- a/modules/postgresql/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider +++ b/modules/postgresql/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -1 +1,4 @@ -org.testcontainers.containers.PostgreSQLContainerProvider \ No newline at end of file +org.testcontainers.containers.PostgreSQLContainerProvider +org.testcontainers.containers.PostgisContainerProvider +org.testcontainers.containers.TimescaleDBContainerProvider +org.testcontainers.containers.PgVectorContainerProvider diff --git a/modules/postgresql/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider b/modules/postgresql/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider new file mode 100644 index 00000000000..6224c0e6093 --- /dev/null +++ b/modules/postgresql/src/main/resources/META-INF/services/org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.containers.PostgreSQLR2DBCDatabaseContainerProvider diff --git a/modules/postgresql/src/test/java/org/testcontainers/PostgreSQLTestImages.java b/modules/postgresql/src/test/java/org/testcontainers/PostgreSQLTestImages.java new file mode 100644 index 00000000000..8be01053570 --- /dev/null +++ b/modules/postgresql/src/test/java/org/testcontainers/PostgreSQLTestImages.java @@ -0,0 +1,7 @@ +package org.testcontainers; + +import org.testcontainers.utility.DockerImageName; + +public interface PostgreSQLTestImages { + DockerImageName POSTGRES_TEST_IMAGE = DockerImageName.parse("postgres:9.6.12"); +} diff --git a/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLConnectionURLTest.java b/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLConnectionURLTest.java new file mode 100644 index 00000000000..3ed0b7030b1 --- /dev/null +++ b/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLConnectionURLTest.java @@ -0,0 +1,84 @@ +package org.testcontainers.containers; + +import org.junit.jupiter.api.Test; +import org.testcontainers.PostgreSQLTestImages; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +class PostgreSQLConnectionURLTest { + + @Test + void shouldCorrectlyAppendQueryString() { + PostgreSQLContainer postgres = new FixedJdbcUrlPostgreSQLContainer(); + String connectionUrl = postgres.constructUrlForConnection("?stringtype=unspecified&stringtype=unspecified"); + String queryString = connectionUrl.substring(connectionUrl.indexOf('?')); + + assertThat(queryString) + .as("Query String contains expected params") + .contains("?stringtype=unspecified&stringtype=unspecified"); + assertThat(queryString.indexOf('?')).as("Query String starts with '?'").isZero(); + assertThat(queryString.substring(1)).as("Query String does not contain extra '?'").doesNotContain("?"); + } + + @Test + void shouldCorrectlyAppendQueryStringWhenNoBaseParams() { + PostgreSQLContainer postgres = new NoParamsUrlPostgreSQLContainer(); + String connectionUrl = postgres.constructUrlForConnection("?stringtype=unspecified&stringtype=unspecified"); + String queryString = connectionUrl.substring(connectionUrl.indexOf('?')); + + assertThat(queryString) + .as("Query String contains expected params") + .contains("?stringtype=unspecified&stringtype=unspecified"); + assertThat(queryString.indexOf('?')).as("Query String starts with '?'").isZero(); + assertThat(queryString.substring(1)).as("Query String does not contain extra '?'").doesNotContain("?"); + } + + @Test + void shouldReturnOriginalURLWhenEmptyQueryString() { + PostgreSQLContainer postgres = new FixedJdbcUrlPostgreSQLContainer(); + String connectionUrl = postgres.constructUrlForConnection(""); + + assertThat(postgres.getJdbcUrl()).as("Query String remains unchanged").isEqualTo(connectionUrl); + } + + @Test + void shouldRejectInvalidQueryString() { + assertThat( + catchThrowable(() -> { + new NoParamsUrlPostgreSQLContainer().constructUrlForConnection("stringtype=unspecified"); + }) + ) + .as("Fails when invalid query string provided") + .isInstanceOf(IllegalArgumentException.class); + } + + static class FixedJdbcUrlPostgreSQLContainer extends PostgreSQLContainer { + + public FixedJdbcUrlPostgreSQLContainer() { + super(PostgreSQLTestImages.POSTGRES_TEST_IMAGE); + } + + @Override + public String getHost() { + return "localhost"; + } + + @Override + public Integer getMappedPort(int originalPort) { + return 34532; + } + } + + static class NoParamsUrlPostgreSQLContainer extends PostgreSQLContainer { + + public NoParamsUrlPostgreSQLContainer() { + super(PostgreSQLTestImages.POSTGRES_TEST_IMAGE); + } + + @Override + public String getJdbcUrl() { + return "jdbc:postgresql://host:port/database"; + } + } +} diff --git a/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerTest.java b/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..fb503bfd659 --- /dev/null +++ b/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerTest.java @@ -0,0 +1,30 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.PostgreSQLTestImages; +import org.testcontainers.r2dbc.AbstractR2DBCDatabaseContainerTest; + +public class PostgreSQLR2DBCDatabaseContainerTest extends AbstractR2DBCDatabaseContainerTest> { + + @Override + protected PostgreSQLContainer createContainer() { + return new PostgreSQLContainer<>(PostgreSQLTestImages.POSTGRES_TEST_IMAGE); + } + + @Override + protected ConnectionFactoryOptions getOptions(PostgreSQLContainer container) { + // spotless:off + // get_options { + ConnectionFactoryOptions options = PostgreSQLR2DBCDatabaseContainer.getOptions( + container + ); + // } + // spotless:on + + return options; + } + + protected String createR2DBCUrl() { + return "r2dbc:tc:postgresql:///db?TC_IMAGE_TAG=10-alpine"; + } +} diff --git a/modules/postgresql/src/test/java/org/testcontainers/containers/TimescaleDBContainerTest.java b/modules/postgresql/src/test/java/org/testcontainers/containers/TimescaleDBContainerTest.java new file mode 100644 index 00000000000..3991f4c2b1c --- /dev/null +++ b/modules/postgresql/src/test/java/org/testcontainers/containers/TimescaleDBContainerTest.java @@ -0,0 +1,76 @@ +package org.testcontainers.containers; + +import org.junit.jupiter.api.Test; +import org.testcontainers.db.AbstractContainerDatabaseTest; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +class TimescaleDBContainerTest extends AbstractContainerDatabaseTest { + + @Test + void testSimple() throws SQLException { + try (JdbcDatabaseContainer postgres = new TimescaleDBContainerProvider().newInstance()) { + postgres.start(); + + ResultSet resultSet = performQuery(postgres, "SELECT 1"); + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + } + } + + @Test + void testCommandOverride() throws SQLException { + try ( + GenericContainer postgres = new TimescaleDBContainerProvider() + .newInstance() + .withCommand("postgres -c max_connections=42") + ) { + postgres.start(); + + ResultSet resultSet = performQuery( + (JdbcDatabaseContainer) postgres, + "SELECT current_setting('max_connections')" + ); + String result = resultSet.getString(1); + assertThat(result).as("max_connections should be overridden").isEqualTo("42"); + } + } + + @Test + void testUnsetCommand() throws SQLException { + try ( + GenericContainer postgres = new TimescaleDBContainerProvider() + .newInstance() + .withCommand("postgres -c max_connections=42") + .withCommand() + ) { + postgres.start(); + + ResultSet resultSet = performQuery( + (JdbcDatabaseContainer) postgres, + "SELECT current_setting('max_connections')" + ); + String result = resultSet.getString(1); + assertThat(result).as("max_connections should not be overridden").isNotEqualTo("42"); + } + } + + @Test + void testExplicitInitScript() throws SQLException { + try ( + JdbcDatabaseContainer postgres = new TimescaleDBContainerProvider() + .newInstance() + .withInitScript("somepath/init_timescaledb.sql") + ) { + postgres.start(); + + ResultSet resultSet = performQuery(postgres, "SELECT foo FROM bar"); + + String firstColumnValue = resultSet.getString(1); + assertThat(firstColumnValue).as("Value from init script should equal real value").isEqualTo("hello world"); + } + } +} diff --git a/modules/postgresql/src/test/java/org/testcontainers/jdbc/DatabaseDriverShutdownTest.java b/modules/postgresql/src/test/java/org/testcontainers/jdbc/DatabaseDriverShutdownTest.java new file mode 100644 index 00000000000..ab4d6f03d2e --- /dev/null +++ b/modules/postgresql/src/test/java/org/testcontainers/jdbc/DatabaseDriverShutdownTest.java @@ -0,0 +1,52 @@ +package org.testcontainers.jdbc; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.JdbcDatabaseContainer; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * This test belongs in the jdbc module, as it is focused on testing the behaviour of {@link org.testcontainers.containers.JdbcDatabaseContainer}. + * However, the need to use the {@link org.testcontainers.containers.PostgreSQLContainerProvider} (due to the jdbc:tc:postgresql) URL forces it to live here in + * the mysql module, to avoid circular dependencies. + * TODO: Move to the jdbc module and either (a) implement a barebones {@link org.testcontainers.containers.JdbcDatabaseContainerProvider} for testing, or (b) refactor into a unit test. + */ +class DatabaseDriverShutdownTest { + + @BeforeAll + public static void testCleanup() { + ContainerDatabaseDriver.killContainers(); + } + + @Test + void shouldStopContainerWhenAllConnectionsClosed() throws SQLException { + final String jdbcUrl = "jdbc:tc:postgresql:9.6.8://hostname/databasename"; + + getConnectionAndClose(jdbcUrl); + + JdbcDatabaseContainer container = ContainerDatabaseDriver.getContainer(jdbcUrl); + assertThat(container).as("Database container instance is null as expected").isNull(); + } + + @Test + void shouldNotStopDaemonContainerWhenAllConnectionsClosed() throws SQLException { + final String jdbcUrl = "jdbc:tc:postgresql:9.6.8://hostname/databasename?TC_DAEMON=true"; + + getConnectionAndClose(jdbcUrl); + + JdbcDatabaseContainer container = ContainerDatabaseDriver.getContainer(jdbcUrl); + assertThat(container).as("Database container instance is not null as expected").isNotNull(); + assertThat(container.isRunning()).as("Database container is running as expected").isTrue(); + } + + private void getConnectionAndClose(String jdbcUrl) throws SQLException { + try (Connection connection = DriverManager.getConnection(jdbcUrl)) { + assertThat(connection).as("Obtained connection as expected").isNotNull(); + } + } +} diff --git a/modules/postgresql/src/test/java/org/testcontainers/jdbc/DatabaseDriverTmpfsTest.java b/modules/postgresql/src/test/java/org/testcontainers/jdbc/DatabaseDriverTmpfsTest.java new file mode 100644 index 00000000000..12a6f2216cf --- /dev/null +++ b/modules/postgresql/src/test/java/org/testcontainers/jdbc/DatabaseDriverTmpfsTest.java @@ -0,0 +1,37 @@ +package org.testcontainers.jdbc; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.JdbcDatabaseContainer; + +import java.sql.Connection; +import java.sql.DriverManager; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * This test belongs in the jdbc module, as it is focused on testing the behaviour of {@link org.testcontainers.containers.JdbcDatabaseContainer}. + * However, the need to use the {@link org.testcontainers.containers.PostgreSQLContainerProvider} (due to the jdbc:tc:postgresql) URL forces it to live here in + * the mysql module, to avoid circular dependencies. + * TODO: Move to the jdbc module and either (a) implement a barebones {@link org.testcontainers.containers.JdbcDatabaseContainerProvider} for testing, or (b) refactor into a unit test. + */ +class DatabaseDriverTmpfsTest { + + @Test + void testDatabaseHasTmpFsViaConnectionString() throws Exception { + final String jdbcUrl = "jdbc:tc:postgresql:9.6.8://hostname/databasename?TC_TMPFS=/testtmpfs:rw"; + try (Connection ignored = DriverManager.getConnection(jdbcUrl)) { + JdbcDatabaseContainer container = ContainerDatabaseDriver.getContainer(jdbcUrl); + // check file doesn't exist + String path = "/testtmpfs/test.file"; + Container.ExecResult execResult = container.execInContainer("ls", path); + assertThat(execResult.getExitCode()) + .as("tmpfs inside container doesn't have file that doesn't exist") + .isNotZero(); + // touch && check file does exist + container.execInContainer("touch", path); + execResult = container.execInContainer("ls", path); + assertThat(execResult.getExitCode()).as("tmpfs inside container has file that does exist").isZero(); + } + } +} diff --git a/modules/postgresql/src/test/java/org/testcontainers/jdbc/pgvector/PgVectorJDBCDriverTest.java b/modules/postgresql/src/test/java/org/testcontainers/jdbc/pgvector/PgVectorJDBCDriverTest.java new file mode 100644 index 00000000000..e94986e7875 --- /dev/null +++ b/modules/postgresql/src/test/java/org/testcontainers/jdbc/pgvector/PgVectorJDBCDriverTest.java @@ -0,0 +1,24 @@ +package org.testcontainers.jdbc.pgvector; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +class PgVectorJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { + { + "jdbc:tc:pgvector://hostname/databasename?user=someuser&password=somepwd", + EnumSet.of(Options.JDBCParams), + }, + { + "jdbc:tc:pgvector:pg14://hostname/databasename?user=someuser&password=somepwd", + EnumSet.of(Options.JDBCParams), + }, + } + ); + } +} diff --git a/modules/postgresql/src/test/java/org/testcontainers/jdbc/postgis/PostgisJDBCDriverTest.java b/modules/postgresql/src/test/java/org/testcontainers/jdbc/postgis/PostgisJDBCDriverTest.java new file mode 100644 index 00000000000..d6a0047a23c --- /dev/null +++ b/modules/postgresql/src/test/java/org/testcontainers/jdbc/postgis/PostgisJDBCDriverTest.java @@ -0,0 +1,24 @@ +package org.testcontainers.jdbc.postgis; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +class PostgisJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { + { + "jdbc:tc:postgis://hostname/databasename?user=someuser&password=somepwd", + EnumSet.of(Options.JDBCParams), + }, + { + "jdbc:tc:postgis:9.6-2.5://hostname/databasename?user=someuser&password=somepwd", + EnumSet.of(Options.JDBCParams), + }, + } + ); + } +} diff --git a/modules/postgresql/src/test/java/org/testcontainers/jdbc/postgresql/PostgreSQLJDBCDriverTest.java b/modules/postgresql/src/test/java/org/testcontainers/jdbc/postgresql/PostgreSQLJDBCDriverTest.java new file mode 100644 index 00000000000..b47af39f54b --- /dev/null +++ b/modules/postgresql/src/test/java/org/testcontainers/jdbc/postgresql/PostgreSQLJDBCDriverTest.java @@ -0,0 +1,20 @@ +package org.testcontainers.jdbc.postgresql; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +public class PostgreSQLJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { + { + "jdbc:tc:postgresql:9.6.8://hostname/databasename?user=someuser&password=somepwd", + EnumSet.of(Options.JDBCParams), + }, + } + ); + } +} diff --git a/modules/postgresql/src/test/java/org/testcontainers/jdbc/timescaledb/TimescaleDBJDBCDriverTest.java b/modules/postgresql/src/test/java/org/testcontainers/jdbc/timescaledb/TimescaleDBJDBCDriverTest.java new file mode 100644 index 00000000000..8befb01bfb1 --- /dev/null +++ b/modules/postgresql/src/test/java/org/testcontainers/jdbc/timescaledb/TimescaleDBJDBCDriverTest.java @@ -0,0 +1,24 @@ +package org.testcontainers.jdbc.timescaledb; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +public class TimescaleDBJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { + { + "jdbc:tc:timescaledb://hostname/databasename?user=someuser&password=somepwd", + EnumSet.of(Options.JDBCParams), + }, + { + "jdbc:tc:timescaledb:2.1.0-pg13://hostname/databasename?user=someuser&password=somepwd", + EnumSet.of(Options.JDBCParams), + }, + } + ); + } +} diff --git a/modules/postgresql/src/test/java/org/testcontainers/postgresql/CompatibleImageTest.java b/modules/postgresql/src/test/java/org/testcontainers/postgresql/CompatibleImageTest.java new file mode 100644 index 00000000000..bdad4fb6a5c --- /dev/null +++ b/modules/postgresql/src/test/java/org/testcontainers/postgresql/CompatibleImageTest.java @@ -0,0 +1,62 @@ +package org.testcontainers.postgresql; + +import org.junit.jupiter.api.Test; +import org.testcontainers.db.AbstractContainerDatabaseTest; +import org.testcontainers.utility.DockerImageName; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +class CompatibleImageTest extends AbstractContainerDatabaseTest { + + @Test + void pgvector() throws SQLException { + try ( + // pgvectorContainer { + PostgreSQLContainer pgvector = new PostgreSQLContainer("pgvector/pgvector:pg16") + // } + ) { + pgvector.start(); + + ResultSet resultSet = performQuery(pgvector, "SELECT 1"); + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + } + } + + @Test + void postgis() throws SQLException { + try ( + // postgisContainer { + PostgreSQLContainer postgis = new PostgreSQLContainer( + DockerImageName.parse("postgis/postgis:16-3.4-alpine").asCompatibleSubstituteFor("postgres") + ) + // } + ) { + postgis.start(); + + ResultSet resultSet = performQuery(postgis, "SELECT 1"); + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + } + } + + @Test + void timescaledb() throws SQLException { + try ( + // timescaledbContainer { + PostgreSQLContainer timescaledb = new PostgreSQLContainer( + DockerImageName.parse("timescale/timescaledb:2.14.2-pg16").asCompatibleSubstituteFor("postgres") + ) + // } + ) { + timescaledb.start(); + + ResultSet resultSet = performQuery(timescaledb, "SELECT 1"); + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + } + } +} diff --git a/modules/postgresql/src/test/java/org/testcontainers/postgresql/PostgreSQLContainerTest.java b/modules/postgresql/src/test/java/org/testcontainers/postgresql/PostgreSQLContainerTest.java new file mode 100644 index 00000000000..beefa3776b1 --- /dev/null +++ b/modules/postgresql/src/test/java/org/testcontainers/postgresql/PostgreSQLContainerTest.java @@ -0,0 +1,130 @@ +package org.testcontainers.postgresql; + +import org.junit.jupiter.api.Test; +import org.testcontainers.PostgreSQLTestImages; +import org.testcontainers.db.AbstractContainerDatabaseTest; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.logging.Level; +import java.util.logging.LogManager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +class PostgreSQLContainerTest extends AbstractContainerDatabaseTest { + static { + // Postgres JDBC driver uses JUL; disable it to avoid annoying, irrelevant, stderr logs during connection testing + LogManager.getLogManager().getLogger("").setLevel(Level.OFF); + } + + @Test + void testSimple() throws SQLException { + try ( // container { + PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:9.6.12") + // } + ) { + postgres.start(); + + ResultSet resultSet = performQuery(postgres, "SELECT 1"); + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + assertHasCorrectExposedAndLivenessCheckPorts(postgres); + } + } + + @Test + void testCommandOverride() throws SQLException { + try ( + PostgreSQLContainer postgres = new PostgreSQLContainer(PostgreSQLTestImages.POSTGRES_TEST_IMAGE) + .withCommand("postgres -c max_connections=42") + ) { + postgres.start(); + + ResultSet resultSet = performQuery(postgres, "SELECT current_setting('max_connections')"); + String result = resultSet.getString(1); + assertThat(result).as("max_connections should be overridden").isEqualTo("42"); + } + } + + @Test + void testUnsetCommand() throws SQLException { + try ( + PostgreSQLContainer postgres = new PostgreSQLContainer(PostgreSQLTestImages.POSTGRES_TEST_IMAGE) + .withCommand("postgres -c max_connections=42") + .withCommand() + ) { + postgres.start(); + + ResultSet resultSet = performQuery(postgres, "SELECT current_setting('max_connections')"); + String result = resultSet.getString(1); + assertThat(result).as("max_connections should not be overridden").isNotEqualTo("42"); + } + } + + @Test + void testMissingInitScript() { + try ( + PostgreSQLContainer postgres = new PostgreSQLContainer(PostgreSQLTestImages.POSTGRES_TEST_IMAGE) + .withInitScript(null) + ) { + assertThatNoException().isThrownBy(postgres::start); + } + } + + @Test + void testExplicitInitScript() throws SQLException { + try ( + PostgreSQLContainer postgres = new PostgreSQLContainer(PostgreSQLTestImages.POSTGRES_TEST_IMAGE) + .withInitScript("somepath/init_postgresql.sql") + ) { + postgres.start(); + + ResultSet resultSet = performQuery(postgres, "SELECT foo FROM bar"); + + String firstColumnValue = resultSet.getString(1); + assertThat(firstColumnValue).as("Value from init script should equal real value").isEqualTo("hello world"); + } + } + + @Test + void testExplicitInitScripts() throws SQLException { + try ( + PostgreSQLContainer postgres = new PostgreSQLContainer(PostgreSQLTestImages.POSTGRES_TEST_IMAGE) + .withInitScripts("somepath/init_postgresql.sql", "somepath/init_postgresql_2.sql") + ) { + postgres.start(); + + ResultSet resultSet = performQuery( + postgres, + "SELECT foo AS value FROM bar UNION SELECT bar AS value FROM foo" + ); + + String columnValue1 = resultSet.getString(1); + resultSet.next(); + String columnValue2 = resultSet.getString(1); + assertThat(columnValue1).as("Value from init script 1 should equal real value").isEqualTo("hello world"); + assertThat(columnValue2).as("Value from init script 2 should equal real value").isEqualTo("hello world 2"); + } + } + + @Test + void testWithAdditionalUrlParamInJdbcUrl() { + try ( + PostgreSQLContainer postgres = new PostgreSQLContainer(PostgreSQLTestImages.POSTGRES_TEST_IMAGE) + .withUrlParam("charSet", "UNICODE") + ) { + postgres.start(); + String jdbcUrl = postgres.getJdbcUrl(); + assertThat(jdbcUrl).contains("?"); + assertThat(jdbcUrl).contains("&"); + assertThat(jdbcUrl).contains("charSet=UNICODE"); + } + } + + private void assertHasCorrectExposedAndLivenessCheckPorts(PostgreSQLContainer postgres) { + assertThat(postgres.getExposedPorts()).containsExactly(PostgreSQLContainer.POSTGRESQL_PORT); + assertThat(postgres.getLivenessCheckPortNumbers()) + .containsExactly(postgres.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT)); + } +} diff --git a/modules/postgresql/src/test/java/org/testcontainers/postgresql/PostgreSQLR2DBCDatabaseContainerTest.java b/modules/postgresql/src/test/java/org/testcontainers/postgresql/PostgreSQLR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..4f5386d9f58 --- /dev/null +++ b/modules/postgresql/src/test/java/org/testcontainers/postgresql/PostgreSQLR2DBCDatabaseContainerTest.java @@ -0,0 +1,30 @@ +package org.testcontainers.postgresql; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.PostgreSQLTestImages; +import org.testcontainers.r2dbc.AbstractR2DBCDatabaseContainerTest; + +public class PostgreSQLR2DBCDatabaseContainerTest extends AbstractR2DBCDatabaseContainerTest { + + @Override + protected PostgreSQLContainer createContainer() { + return new PostgreSQLContainer(PostgreSQLTestImages.POSTGRES_TEST_IMAGE); + } + + @Override + protected ConnectionFactoryOptions getOptions(PostgreSQLContainer container) { + // spotless:off + // get_options { + ConnectionFactoryOptions options = PostgreSQLR2DBCDatabaseContainer.getOptions( + container + ); + // } + // spotless:on + + return options; + } + + protected String createR2DBCUrl() { + return "r2dbc:tc:postgresql:///db?TC_IMAGE_TAG=10-alpine"; + } +} diff --git a/modules/postgresql/src/test/resources/logback-test.xml b/modules/postgresql/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/postgresql/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/postgresql/src/test/resources/somepath/init_postgresql.sql b/modules/postgresql/src/test/resources/somepath/init_postgresql.sql new file mode 100644 index 00000000000..2b00ee968b0 --- /dev/null +++ b/modules/postgresql/src/test/resources/somepath/init_postgresql.sql @@ -0,0 +1,5 @@ +CREATE TABLE bar ( + foo VARCHAR(255) +); + +INSERT INTO bar (foo) VALUES ('hello world'); \ No newline at end of file diff --git a/modules/postgresql/src/test/resources/somepath/init_postgresql_2.sql b/modules/postgresql/src/test/resources/somepath/init_postgresql_2.sql new file mode 100644 index 00000000000..f4ecf9bbfad --- /dev/null +++ b/modules/postgresql/src/test/resources/somepath/init_postgresql_2.sql @@ -0,0 +1,5 @@ +CREATE TABLE foo ( + bar VARCHAR(255) +); + +INSERT INTO foo (bar) VALUES ('hello world 2'); diff --git a/modules/postgresql/src/test/resources/somepath/init_timescaledb.sql b/modules/postgresql/src/test/resources/somepath/init_timescaledb.sql new file mode 100644 index 00000000000..da11604cf10 --- /dev/null +++ b/modules/postgresql/src/test/resources/somepath/init_timescaledb.sql @@ -0,0 +1,13 @@ +-- Extend the database with TimescaleDB +CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE; + +CREATE TABLE bar +( + foo VARCHAR(255), + time TIMESTAMPTZ NOT NULL +); + +SELECT create_hypertable('bar', 'time'); + +INSERT INTO bar (time, foo) +VALUES (CURRENT_TIMESTAMP, 'hello world'); diff --git a/modules/presto/build.gradle b/modules/presto/build.gradle new file mode 100644 index 00000000000..1d2933dec53 --- /dev/null +++ b/modules/presto/build.gradle @@ -0,0 +1,9 @@ +description = "Testcontainers :: JDBC :: Presto" + +dependencies { + api project(':testcontainers-jdbc') + + testImplementation project(':testcontainers-jdbc-test') + testRuntimeOnly 'io.prestosql:presto-jdbc:350' + compileOnly 'org.jetbrains:annotations:26.1.0' +} diff --git a/modules/presto/src/main/java/org/testcontainers/containers/PrestoContainer.java b/modules/presto/src/main/java/org/testcontainers/containers/PrestoContainer.java new file mode 100644 index 00000000000..ac01eff1882 --- /dev/null +++ b/modules/presto/src/main/java/org/testcontainers/containers/PrestoContainer.java @@ -0,0 +1,131 @@ +package org.testcontainers.containers; + +import com.google.common.base.Strings; +import org.jetbrains.annotations.NotNull; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Set; + +/** + * @deprecated Use {@code TrinoContainer} instead. + */ +@Deprecated +public class PrestoContainer> extends JdbcDatabaseContainer { + + public static final String NAME = "presto"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("ghcr.io/trinodb/presto"); + + public static final String IMAGE = "ghcr.io/trinodb/presto"; + + public static final String DEFAULT_TAG = "344"; + + public static final Integer PRESTO_PORT = 8080; + + private String username = "test"; + + private String catalog = null; + + /** + * @deprecated use {@link #PrestoContainer(DockerImageName)} instead + */ + @Deprecated + public PrestoContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + public PrestoContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public PrestoContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + waitingFor( + Wait + .forLogMessage(".*======== SERVER STARTED ========.*", 1) + .withStartupTimeout(Duration.of(60, ChronoUnit.SECONDS)) + ); + + addExposedPort(PRESTO_PORT); + } + + /** + * @return the ports on which to check if the container is ready + * @deprecated use {@link #getLivenessCheckPortNumbers()} instead + */ + @NotNull + @Override + @Deprecated + protected Set getLivenessCheckPorts() { + return super.getLivenessCheckPorts(); + } + + @Override + public String getDriverClassName() { + return "io.prestosql.jdbc.PrestoDriver"; + } + + @Override + public String getJdbcUrl() { + return String.format( + "jdbc:presto://%s:%s/%s", + getHost(), + getMappedPort(PRESTO_PORT), + Strings.nullToEmpty(catalog) + ); + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return ""; + } + + @Override + public String getDatabaseName() { + return catalog; + } + + @Override + public String getTestQueryString() { + return "SELECT count(*) FROM tpch.tiny.nation"; + } + + @Override + public SELF withUsername(final String username) { + this.username = username; + return self(); + } + + /** + * @deprecated This operation is not supported. + */ + @Override + @Deprecated + public SELF withPassword(final String password) { + // ignored, Presto does not support password authentication without TLS + // TODO: make JDBCDriverTest not pass a password unconditionally and remove this method + return self(); + } + + @Override + public SELF withDatabaseName(String dbName) { + this.catalog = dbName; + return self(); + } + + public Connection createConnection() throws SQLException, NoDriverFoundException { + return createConnection(""); + } +} diff --git a/modules/presto/src/main/java/org/testcontainers/containers/PrestoContainerProvider.java b/modules/presto/src/main/java/org/testcontainers/containers/PrestoContainerProvider.java new file mode 100644 index 00000000000..f37efb9438e --- /dev/null +++ b/modules/presto/src/main/java/org/testcontainers/containers/PrestoContainerProvider.java @@ -0,0 +1,34 @@ +package org.testcontainers.containers; + +import org.testcontainers.jdbc.ConnectionUrl; +import org.testcontainers.utility.DockerImageName; + +/** + * Factory for Presto containers. + */ +public class PrestoContainerProvider extends JdbcDatabaseContainerProvider { + + public static final String USER_PARAM = "user"; + + public static final String PASSWORD_PARAM = "password"; + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(PrestoContainer.NAME); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(PrestoContainer.DEFAULT_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + return new PrestoContainer(DockerImageName.parse(PrestoContainer.IMAGE).withTag(tag)); + } + + @Override + public JdbcDatabaseContainer newInstance(ConnectionUrl connectionUrl) { + return newInstanceFromConnectionUrl(connectionUrl, USER_PARAM, PASSWORD_PARAM); + } +} diff --git a/modules/presto/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/presto/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 00000000000..ad36a36a117 --- /dev/null +++ b/modules/presto/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.containers.PrestoContainerProvider diff --git a/modules/presto/src/test/java/org/testcontainers/PrestoTestImages.java b/modules/presto/src/test/java/org/testcontainers/PrestoTestImages.java new file mode 100644 index 00000000000..498e09f0be4 --- /dev/null +++ b/modules/presto/src/test/java/org/testcontainers/PrestoTestImages.java @@ -0,0 +1,9 @@ +package org.testcontainers; + +import org.testcontainers.utility.DockerImageName; + +public interface PrestoTestImages { + DockerImageName PRESTO_TEST_IMAGE = DockerImageName.parse("ghcr.io/trinodb/presto:344"); + + DockerImageName PRESTO_PREVIOUS_VERSION_TEST_IMAGE = DockerImageName.parse("ghcr.io/trinodb/presto:343"); +} diff --git a/modules/presto/src/test/java/org/testcontainers/containers/PrestoContainerTest.java b/modules/presto/src/test/java/org/testcontainers/containers/PrestoContainerTest.java new file mode 100644 index 00000000000..9a46309f9e9 --- /dev/null +++ b/modules/presto/src/test/java/org/testcontainers/containers/PrestoContainerTest.java @@ -0,0 +1,156 @@ +package org.testcontainers.containers; + +import org.junit.jupiter.api.Test; +import org.testcontainers.PrestoTestImages; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PrestoContainerTest { + + @Test + void testSimple() throws Exception { + try (PrestoContainer prestoSql = new PrestoContainer<>(PrestoTestImages.PRESTO_TEST_IMAGE)) { + prestoSql.start(); + try ( + Connection connection = prestoSql.createConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT DISTINCT node_version FROM system.runtime.nodes") + ) { + assertThat(resultSet.next()).as("has result").isTrue(); + assertThat(resultSet.getString("node_version")) + .as("Presto version") + .isEqualTo(PrestoContainer.DEFAULT_TAG); + assertHasCorrectExposedAndLivenessCheckPorts(prestoSql); + } + } + } + + @Test + void testSpecificVersion() throws Exception { + try ( + PrestoContainer prestoSql = new PrestoContainer<>(PrestoTestImages.PRESTO_PREVIOUS_VERSION_TEST_IMAGE) + ) { + prestoSql.start(); + try ( + Connection connection = prestoSql.createConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT DISTINCT node_version FROM system.runtime.nodes") + ) { + assertThat(resultSet.next()).as("has result").isTrue(); + assertThat(resultSet.getString("node_version")) + .as("Presto version") + .isEqualTo(PrestoTestImages.PRESTO_PREVIOUS_VERSION_TEST_IMAGE.getVersionPart()); + } + } + } + + @Test + void testQueryMemoryAndTpch() throws SQLException { + try (PrestoContainer prestoSql = new PrestoContainer<>(PrestoTestImages.PRESTO_TEST_IMAGE)) { + prestoSql.start(); + try ( + Connection connection = prestoSql.createConnection(); + Statement statement = connection.createStatement() + ) { + // Prepare data + statement.execute( + "CREATE TABLE memory.default.table_with_array AS SELECT 1 id, ARRAY[1, 42, 2, 42, 4, 42] my_array" + ); + + // Query Presto using newly created table and a builtin connector + try ( + ResultSet resultSet = statement.executeQuery( + "" + + "SELECT nationkey, element " + + "FROM tpch.tiny.nation " + + "JOIN memory.default.table_with_array twa ON nationkey = twa.id " + + "LEFT JOIN UNNEST(my_array) a(element) ON true " + + "ORDER BY element OFFSET 1 FETCH NEXT 3 ROWS WITH TIES " + ) + ) { + List actualElements = new ArrayList<>(); + while (resultSet.next()) { + actualElements.add(resultSet.getInt("element")); + } + assertThat(actualElements).isEqualTo(Arrays.asList(2, 4, 42, 42, 42)); + } + } + } + } + + @Test + void testInitScript() throws Exception { + try (PrestoContainer prestoSql = new PrestoContainer<>(PrestoTestImages.PRESTO_TEST_IMAGE)) { + prestoSql.withInitScript("initial.sql"); + prestoSql.start(); + try ( + Connection connection = prestoSql.createConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT a FROM memory.default.test_table") + ) { + assertThat(resultSet.next()).as("has result").isTrue(); + assertThat(resultSet.getObject("a")).as("Value").isEqualTo(12345678909324L); + assertThat(resultSet.next()).as("only has one result").isFalse(); + } + } + } + + @Test + void testTcJdbcUri() throws Exception { + try ( + Connection connection = DriverManager.getConnection( + String.format("jdbc:tc:presto:%s://hostname/", PrestoContainer.DEFAULT_TAG) + ) + ) { + // Verify metadata with tc: JDBC connection URI + assertThat(Integer.parseInt(PrestoContainer.DEFAULT_TAG)) + .isEqualTo(connection.getMetaData().getDatabaseMajorVersion()); + + // Verify transactions with tc: JDBC connection URI + assertThat(connection.getAutoCommit()).as("Is autocommit").isTrue(); + connection.setAutoCommit(false); + assertThat(connection.getAutoCommit()).as("Is autocommit").isFalse(); + assertThat(connection.getTransactionIsolation()) + .as("Transaction isolation") + .isEqualTo(Connection.TRANSACTION_READ_UNCOMMITTED); + + try (Statement statement = connection.createStatement()) { + assertThat(statement.executeUpdate("CREATE TABLE memory.default.test_tc(a bigint)")) + .as("Update result") + .isEqualTo(0); + try ( + ResultSet resultSet = statement.executeQuery( + "SELECT sum(cast(node_version AS bigint)) AS v FROM system.runtime.nodes" + ) + ) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getString("v")).isEqualTo(PrestoContainer.DEFAULT_TAG); + assertThat(resultSet.next()).isFalse(); + } + connection.commit(); + } finally { + connection.rollback(); + } + connection.setAutoCommit(true); + assertThat(connection.getAutoCommit()).as("Is autocommit").isTrue(); + assertThat(connection.getTransactionIsolation()) + .as("Transaction isolation should be retained") + .isEqualTo(Connection.TRANSACTION_READ_UNCOMMITTED); + } + } + + private void assertHasCorrectExposedAndLivenessCheckPorts(PrestoContainer prestoSql) { + assertThat(prestoSql.getExposedPorts()).containsExactly(PrestoContainer.PRESTO_PORT); + assertThat(prestoSql.getLivenessCheckPortNumbers()) + .containsExactly(prestoSql.getMappedPort(PrestoContainer.PRESTO_PORT)); + } +} diff --git a/modules/presto/src/test/java/org/testcontainers/jdbc/presto/PrestoJDBCDriverTest.java b/modules/presto/src/test/java/org/testcontainers/jdbc/presto/PrestoJDBCDriverTest.java new file mode 100644 index 00000000000..02e5b2a0485 --- /dev/null +++ b/modules/presto/src/test/java/org/testcontainers/jdbc/presto/PrestoJDBCDriverTest.java @@ -0,0 +1,17 @@ +package org.testcontainers.jdbc.presto; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +class PrestoJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { // + { "jdbc:tc:presto:344://hostname/", EnumSet.of(Options.PmdKnownBroken) }, + } + ); + } +} diff --git a/modules/presto/src/test/resources/initial.sql b/modules/presto/src/test/resources/initial.sql new file mode 100644 index 00000000000..1a3ecdab4f9 --- /dev/null +++ b/modules/presto/src/test/resources/initial.sql @@ -0,0 +1,2 @@ +CREATE TABLE memory.default.test_table(a bigint); +INSERT INTO memory.default.test_table(a) VALUES (12345678909324); diff --git a/modules/presto/src/test/resources/logback-test.xml b/modules/presto/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/presto/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/pulsar/build.gradle b/modules/pulsar/build.gradle new file mode 100644 index 00000000000..9dee1058aa5 --- /dev/null +++ b/modules/pulsar/build.gradle @@ -0,0 +1,9 @@ +description = "Testcontainers :: Pulsar" + +dependencies { + api project(':testcontainers') + + testImplementation platform("org.apache.pulsar:pulsar-bom:4.2.0") + testImplementation 'org.apache.pulsar:pulsar-client' + testImplementation 'org.apache.pulsar:pulsar-client-admin' +} diff --git a/modules/pulsar/src/main/java/org/testcontainers/containers/PulsarContainer.java b/modules/pulsar/src/main/java/org/testcontainers/containers/PulsarContainer.java new file mode 100644 index 00000000000..7305fbd622c --- /dev/null +++ b/modules/pulsar/src/main/java/org/testcontainers/containers/PulsarContainer.java @@ -0,0 +1,119 @@ +package org.testcontainers.containers; + +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for Apache Pulsar. + *

+ * Supported images: {@code apachepulsar/pulsar}, {@code apachepulsar/pulsar-all} + *

+ * Exposed ports: + *

    + *
  • Pulsar: 6650
  • + *
  • HTTP: 8080
  • + *
+ * + * @deprecated use {@link org.testcontainers.pulsar.PulsarContainer} instead. + */ +@Deprecated +public class PulsarContainer extends GenericContainer { + + public static final int BROKER_PORT = 6650; + + public static final int BROKER_HTTP_PORT = 8080; + + private static final String ADMIN_CLUSTERS_ENDPOINT = "/admin/v2/clusters"; + + /** + * See SystemTopicNames. + */ + private static final String TRANSACTION_TOPIC_ENDPOINT = + "/admin/v2/persistent/pulsar/system/transaction_coordinator_assign/partitions"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("apachepulsar/pulsar"); + + @Deprecated + private static final String DEFAULT_TAG = "3.0.0"; + + private final WaitAllStrategy waitAllStrategy = new WaitAllStrategy(); + + private boolean functionsWorkerEnabled = false; + + private boolean transactionsEnabled = false; + + /** + * @deprecated use {@link #PulsarContainer(DockerImageName)} instead + */ + @Deprecated + public PulsarContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + /** + * @deprecated use {@link #PulsarContainer(DockerImageName)} instead + */ + @Deprecated + public PulsarContainer(String pulsarVersion) { + this(DEFAULT_IMAGE_NAME.withTag(pulsarVersion)); + } + + public PulsarContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, DockerImageName.parse("apachepulsar/pulsar-all")); + withExposedPorts(BROKER_PORT, BROKER_HTTP_PORT); + setWaitStrategy(waitAllStrategy); + } + + @Override + protected void configure() { + super.configure(); + setupCommandAndEnv(); + } + + public PulsarContainer withFunctionsWorker() { + functionsWorkerEnabled = true; + return this; + } + + public PulsarContainer withTransactions() { + transactionsEnabled = true; + return this; + } + + public String getPulsarBrokerUrl() { + return String.format("pulsar://%s:%s", getHost(), getMappedPort(BROKER_PORT)); + } + + public String getHttpServiceUrl() { + return String.format("http://%s:%s", getHost(), getMappedPort(BROKER_HTTP_PORT)); + } + + protected void setupCommandAndEnv() { + String standaloneBaseCommand = + "/pulsar/bin/apply-config-from-env.py /pulsar/conf/standalone.conf " + "&& bin/pulsar standalone"; + + if (!functionsWorkerEnabled) { + standaloneBaseCommand += " --no-functions-worker -nss"; + } + + withCommand("/bin/bash", "-c", standaloneBaseCommand); + + final String clusterName = getEnvMap().getOrDefault("PULSAR_PREFIX_clusterName", "standalone"); + final String response = String.format("[\"%s\"]", clusterName); + waitAllStrategy.withStrategy( + Wait.forHttp(ADMIN_CLUSTERS_ENDPOINT).forPort(BROKER_HTTP_PORT).forResponsePredicate(response::equals) + ); + + if (transactionsEnabled) { + withEnv("PULSAR_PREFIX_transactionCoordinatorEnabled", "true"); + waitAllStrategy.withStrategy( + Wait.forHttp(TRANSACTION_TOPIC_ENDPOINT).forStatusCode(200).forPort(BROKER_HTTP_PORT) + ); + } + if (functionsWorkerEnabled) { + waitAllStrategy.withStrategy(Wait.forLogMessage(".*Function worker service started.*", 1)); + } + } +} diff --git a/modules/pulsar/src/main/java/org/testcontainers/pulsar/PulsarContainer.java b/modules/pulsar/src/main/java/org/testcontainers/pulsar/PulsarContainer.java new file mode 100644 index 00000000000..8936a7523c4 --- /dev/null +++ b/modules/pulsar/src/main/java/org/testcontainers/pulsar/PulsarContainer.java @@ -0,0 +1,103 @@ +package org.testcontainers.pulsar; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for Apache Pulsar. + *

+ * Supported images: {@code apachepulsar/pulsar}, {@code apachepulsar/pulsar-all} + *

+ * Exposed ports: + *

    + *
  • Pulsar: 6650
  • + *
  • HTTP: 8080
  • + *
+ */ +public class PulsarContainer extends GenericContainer { + + public static final int BROKER_PORT = 6650; + + public static final int BROKER_HTTP_PORT = 8080; + + private static final String ADMIN_CLUSTERS_ENDPOINT = "/admin/v2/clusters"; + + /** + * See SystemTopicNames. + */ + private static final String TRANSACTION_TOPIC_ENDPOINT = + "/admin/v2/persistent/pulsar/system/transaction_coordinator_assign/partitions"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("apachepulsar/pulsar"); + + private final WaitAllStrategy waitAllStrategy = new WaitAllStrategy(); + + private boolean functionsWorkerEnabled = false; + + private boolean transactionsEnabled = false; + + @Deprecated + public PulsarContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public PulsarContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, DockerImageName.parse("apachepulsar/pulsar-all")); + withExposedPorts(BROKER_PORT, BROKER_HTTP_PORT); + setWaitStrategy(waitAllStrategy); + } + + @Override + protected void configure() { + super.configure(); + setupCommandAndEnv(); + } + + public PulsarContainer withFunctionsWorker() { + functionsWorkerEnabled = true; + return this; + } + + public PulsarContainer withTransactions() { + transactionsEnabled = true; + return this; + } + + public String getPulsarBrokerUrl() { + return String.format("pulsar://%s:%s", getHost(), getMappedPort(BROKER_PORT)); + } + + public String getHttpServiceUrl() { + return String.format("http://%s:%s", getHost(), getMappedPort(BROKER_HTTP_PORT)); + } + + protected void setupCommandAndEnv() { + String standaloneBaseCommand = + "/pulsar/bin/apply-config-from-env.py /pulsar/conf/standalone.conf " + "&& bin/pulsar standalone"; + + if (!functionsWorkerEnabled) { + standaloneBaseCommand += " --no-functions-worker -nss"; + } + + withCommand("/bin/bash", "-c", standaloneBaseCommand); + + final String clusterName = getEnvMap().getOrDefault("PULSAR_PREFIX_clusterName", "standalone"); + final String response = String.format("[\"%s\"]", clusterName); + waitAllStrategy.withStrategy( + Wait.forHttp(ADMIN_CLUSTERS_ENDPOINT).forPort(BROKER_HTTP_PORT).forResponsePredicate(response::equals) + ); + + if (transactionsEnabled) { + withEnv("PULSAR_PREFIX_transactionCoordinatorEnabled", "true"); + waitAllStrategy.withStrategy( + Wait.forHttp(TRANSACTION_TOPIC_ENDPOINT).forStatusCode(200).forPort(BROKER_HTTP_PORT) + ); + } + if (functionsWorkerEnabled) { + waitAllStrategy.withStrategy(Wait.forLogMessage(".*Function worker service started.*", 1)); + } + } +} diff --git a/modules/pulsar/src/test/java/org/testcontainers/pulsar/AbstractPulsar.java b/modules/pulsar/src/test/java/org/testcontainers/pulsar/AbstractPulsar.java new file mode 100644 index 00000000000..c86b594b163 --- /dev/null +++ b/modules/pulsar/src/test/java/org/testcontainers/pulsar/AbstractPulsar.java @@ -0,0 +1,71 @@ +package org.testcontainers.pulsar; + +import org.apache.pulsar.client.admin.ListTopicsOptions; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.client.admin.PulsarAdminException; +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.Message; +import org.apache.pulsar.client.api.Producer; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.Schema; +import org.apache.pulsar.client.api.SubscriptionInitialPosition; +import org.apache.pulsar.client.api.transaction.Transaction; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AbstractPulsar { + + public static final String TEST_TOPIC = "test_topic"; + + protected void testPulsarFunctionality(String pulsarBrokerUrl) throws Exception { + try ( + PulsarClient client = PulsarClient.builder().serviceUrl(pulsarBrokerUrl).build(); + Consumer consumer = client + .newConsumer() + .topic(TEST_TOPIC) + .subscriptionName("test-subs") + .subscribe(); + Producer producer = client.newProducer().topic(TEST_TOPIC).create() + ) { + producer.send("test containers".getBytes()); + CompletableFuture> future = consumer.receiveAsync(); + Message message = future.get(5, TimeUnit.SECONDS); + + assertThat(new String(message.getData())).isEqualTo("test containers"); + } + } + + protected void testTransactionFunctionality(String pulsarBrokerUrl) throws Exception { + try ( + PulsarClient client = PulsarClient.builder().serviceUrl(pulsarBrokerUrl).enableTransaction(true).build(); + Consumer consumer = client + .newConsumer(Schema.STRING) + .topic("transaction-topic") + .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) + .subscriptionName("test-transaction-sub") + .subscribe(); + Producer producer = client + .newProducer(Schema.STRING) + .sendTimeout(0, TimeUnit.SECONDS) + .topic("transaction-topic") + .create() + ) { + final Transaction transaction = client.newTransaction().build().get(); + producer.newMessage(transaction).value("first").send(); + transaction.commit(); + Message message = consumer.receive(); + assertThat(message.getValue()).isEqualTo("first"); + } + } + + protected void assertTransactionsTopicCreated(PulsarAdmin pulsarAdmin) throws PulsarAdminException { + final List topics = pulsarAdmin + .topics() + .getPartitionedTopicList("pulsar/system", ListTopicsOptions.builder().includeSystemTopic(true).build()); + assertThat(topics).contains("persistent://pulsar/system/transaction_coordinator_assign"); + } +} diff --git a/modules/pulsar/src/test/java/org/testcontainers/pulsar/CompatibleApachePulsarImageTest.java b/modules/pulsar/src/test/java/org/testcontainers/pulsar/CompatibleApachePulsarImageTest.java new file mode 100644 index 00000000000..0661994466d --- /dev/null +++ b/modules/pulsar/src/test/java/org/testcontainers/pulsar/CompatibleApachePulsarImageTest.java @@ -0,0 +1,37 @@ +package org.testcontainers.pulsar; + +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.utility.DockerImageName; + +class CompatibleApachePulsarImageTest extends AbstractPulsar { + + public static String[] params() { + return new String[] { "apachepulsar/pulsar:3.0.0", "apachepulsar/pulsar-all:3.0.0" }; + } + + @ParameterizedTest + @MethodSource("params") + void testUsage(String imageName) throws Exception { + try (PulsarContainer pulsar = new PulsarContainer(DockerImageName.parse(imageName));) { + pulsar.start(); + final String pulsarBrokerUrl = pulsar.getPulsarBrokerUrl(); + + testPulsarFunctionality(pulsarBrokerUrl); + } + } + + @ParameterizedTest + @MethodSource("params") + void testTransactions(String imageName) throws Exception { + try (PulsarContainer pulsar = new PulsarContainer(DockerImageName.parse(imageName)).withTransactions();) { + pulsar.start(); + + try (PulsarAdmin pulsarAdmin = PulsarAdmin.builder().serviceHttpUrl(pulsar.getHttpServiceUrl()).build()) { + assertTransactionsTopicCreated(pulsarAdmin); + } + testTransactionFunctionality(pulsar.getPulsarBrokerUrl()); + } + } +} diff --git a/modules/pulsar/src/test/java/org/testcontainers/pulsar/PulsarContainerTest.java b/modules/pulsar/src/test/java/org/testcontainers/pulsar/PulsarContainerTest.java new file mode 100644 index 00000000000..32d96c88453 --- /dev/null +++ b/modules/pulsar/src/test/java/org/testcontainers/pulsar/PulsarContainerTest.java @@ -0,0 +1,133 @@ +package org.testcontainers.pulsar; + +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.client.admin.PulsarAdminException; +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PulsarContainerTest extends AbstractPulsar { + + private static final DockerImageName PULSAR_IMAGE = DockerImageName.parse("apachepulsar/pulsar:3.0.0"); + + @Test + void testUsage() throws Exception { + try ( + // do not use PULSAR_IMAGE to make the doc looks easier + // constructorWithVersion { + PulsarContainer pulsar = new PulsarContainer("apachepulsar/pulsar:3.0.0"); + // } + ) { + pulsar.start(); + // coordinates { + final String pulsarBrokerUrl = pulsar.getPulsarBrokerUrl(); + final String httpServiceUrl = pulsar.getHttpServiceUrl(); + // } + testPulsarFunctionality(pulsarBrokerUrl); + } + } + + @Test + void envVarsUsage() throws Exception { + try ( + // constructorWithEnv { + PulsarContainer pulsar = new PulsarContainer(PULSAR_IMAGE) + .withEnv("PULSAR_PREFIX_brokerDeduplicationEnabled", "true"); + // } + ) { + pulsar.start(); + testPulsarFunctionality(pulsar.getPulsarBrokerUrl()); + } + } + + @Test + void customClusterName() throws Exception { + try ( + PulsarContainer pulsar = new PulsarContainer(PULSAR_IMAGE) + .withEnv("PULSAR_PREFIX_clusterName", "tc-cluster"); + ) { + pulsar.start(); + testPulsarFunctionality(pulsar.getPulsarBrokerUrl()); + } + } + + @Test + void shouldNotEnableFunctionsWorkerByDefault() throws Exception { + try (PulsarContainer pulsar = new PulsarContainer(PULSAR_IMAGE)) { + pulsar.start(); + + try (PulsarAdmin pulsarAdmin = PulsarAdmin.builder().serviceHttpUrl(pulsar.getHttpServiceUrl()).build()) { + assertThatThrownBy(() -> pulsarAdmin.functions().getFunctions("public", "default")) + .isInstanceOf(PulsarAdminException.class); + } + } + } + + @Test + void shouldWaitForFunctionsWorkerStarted() throws Exception { + try ( + // constructorWithFunctionsWorker { + PulsarContainer pulsar = new PulsarContainer(DockerImageName.parse("apachepulsar/pulsar:3.0.0")) + .withFunctionsWorker(); + // } + ) { + pulsar.start(); + + try (PulsarAdmin pulsarAdmin = PulsarAdmin.builder().serviceHttpUrl(pulsar.getHttpServiceUrl()).build()) { + assertThat(pulsarAdmin.functions().getFunctions("public", "default")).hasSize(0); + } + } + } + + @Test + void testTransactions() throws Exception { + try ( + // constructorWithTransactions { + PulsarContainer pulsar = new PulsarContainer(PULSAR_IMAGE).withTransactions(); + // } + ) { + pulsar.start(); + + try (PulsarAdmin pulsarAdmin = PulsarAdmin.builder().serviceHttpUrl(pulsar.getHttpServiceUrl()).build()) { + assertTransactionsTopicCreated(pulsarAdmin); + } + testTransactionFunctionality(pulsar.getPulsarBrokerUrl()); + } + } + + @Test + void testTransactionsAndFunctionsWorker() throws Exception { + try (PulsarContainer pulsar = new PulsarContainer(PULSAR_IMAGE).withTransactions().withFunctionsWorker()) { + pulsar.start(); + + try (PulsarAdmin pulsarAdmin = PulsarAdmin.builder().serviceHttpUrl(pulsar.getHttpServiceUrl()).build();) { + assertTransactionsTopicCreated(pulsarAdmin); + assertThat(pulsarAdmin.functions().getFunctions("public", "default")).hasSize(0); + } + testTransactionFunctionality(pulsar.getPulsarBrokerUrl()); + } + } + + @Test + void testClusterFullyInitialized() throws Exception { + try (PulsarContainer pulsar = new PulsarContainer(PULSAR_IMAGE)) { + pulsar.start(); + + try (PulsarAdmin pulsarAdmin = PulsarAdmin.builder().serviceHttpUrl(pulsar.getHttpServiceUrl()).build()) { + assertThat(pulsarAdmin.clusters().getClusters()).hasSize(1).contains("standalone"); + } + } + } + + @Test + void testStartupTimeoutIsHonored() { + try (PulsarContainer pulsar = new PulsarContainer(PULSAR_IMAGE).withStartupTimeout(Duration.ZERO)) { + assertThatThrownBy(pulsar::start) + .hasRootCauseMessage("Precondition failed: timeout must be greater than zero"); + } + } +} diff --git a/modules/pulsar/src/test/resources/logback-test.xml b/modules/pulsar/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/pulsar/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/qdrant/build.gradle b/modules/qdrant/build.gradle new file mode 100644 index 00000000000..98b8fc403fc --- /dev/null +++ b/modules/qdrant/build.gradle @@ -0,0 +1,11 @@ +description = "Testcontainers :: Qdrant" + +dependencies { + api project(':testcontainers') + + testImplementation 'io.qdrant:client:1.17.0' + testImplementation platform('io.grpc:grpc-bom:1.80.0') + testImplementation 'io.grpc:grpc-stub' + testImplementation 'io.grpc:grpc-protobuf' + testImplementation 'io.grpc:grpc-netty-shaded' +} diff --git a/modules/qdrant/src/main/java/org/testcontainers/qdrant/QdrantContainer.java b/modules/qdrant/src/main/java/org/testcontainers/qdrant/QdrantContainer.java new file mode 100644 index 00000000000..e00ac541d48 --- /dev/null +++ b/modules/qdrant/src/main/java/org/testcontainers/qdrant/QdrantContainer.java @@ -0,0 +1,57 @@ +package org.testcontainers.qdrant; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for Qdrant. + *

+ * Supported image: {@code qdrant/qdrant} + *

+ * Exposed ports: + *

    + *
  • HTTP: 6333
  • + *
  • GRPC: 6334
  • + *
+ */ +public class QdrantContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("qdrant/qdrant"); + + private static final int QDRANT_REST_PORT = 6333; + + private static final int QDRANT_GRPC_PORT = 6334; + + private static final String CONFIG_FILE_PATH = "/qdrant/config/config.yaml"; + + private static final String API_KEY_ENV = "QDRANT__SERVICE__API_KEY"; + + public QdrantContainer(String image) { + this(DockerImageName.parse(image)); + } + + public QdrantContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withExposedPorts(QDRANT_REST_PORT, QDRANT_GRPC_PORT); + waitingFor(Wait.forHttp("/readyz").forPort(QDRANT_REST_PORT)); + } + + public QdrantContainer withApiKey(String apiKey) { + return withEnv(API_KEY_ENV, apiKey); + } + + public QdrantContainer withConfigFile(Transferable configFile) { + return withCopyToContainer(configFile, CONFIG_FILE_PATH); + } + + public int getGrpcPort() { + return getMappedPort(QDRANT_GRPC_PORT); + } + + public String getGrpcHostAddress() { + return getHost() + ":" + getGrpcPort(); + } +} diff --git a/modules/qdrant/src/test/java/org/testcontainers/qdrant/QdrantContainerTest.java b/modules/qdrant/src/test/java/org/testcontainers/qdrant/QdrantContainerTest.java new file mode 100644 index 00000000000..724edf8555c --- /dev/null +++ b/modules/qdrant/src/test/java/org/testcontainers/qdrant/QdrantContainerTest.java @@ -0,0 +1,89 @@ +package org.testcontainers.qdrant; + +import io.qdrant.client.QdrantClient; +import io.qdrant.client.QdrantGrpcClient; +import io.qdrant.client.grpc.QdrantOuterClass; +import org.junit.jupiter.api.Test; +import org.testcontainers.images.builder.Transferable; + +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class QdrantContainerTest { + + @Test + void shouldReturnVersion() throws ExecutionException, InterruptedException { + try ( + // qdrantContainer { + QdrantContainer qdrant = new QdrantContainer("qdrant/qdrant:v1.7.4") + // } + ) { + qdrant.start(); + + QdrantClient client = new QdrantClient( + QdrantGrpcClient.newBuilder(qdrant.getHost(), qdrant.getGrpcPort(), false).build() + ); + QdrantOuterClass.HealthCheckReply healthCheckReply = client.healthCheckAsync().get(); + assertThat(healthCheckReply.getVersion()).isEqualTo("1.7.4"); + + client.close(); + } + } + + @Test + void shouldSetApiKey() throws ExecutionException, InterruptedException { + String apiKey = UUID.randomUUID().toString(); + try (QdrantContainer qdrant = new QdrantContainer("qdrant/qdrant:v1.7.4").withApiKey(apiKey)) { + qdrant.start(); + + final QdrantClient unauthClient = new QdrantClient( + QdrantGrpcClient.newBuilder(qdrant.getHost(), qdrant.getGrpcPort(), false).build() + ); + + assertThatThrownBy(() -> unauthClient.healthCheckAsync().get()).isInstanceOf(ExecutionException.class); + + unauthClient.close(); + + final QdrantClient client = new QdrantClient( + QdrantGrpcClient.newBuilder(qdrant.getHost(), qdrant.getGrpcPort(), false).withApiKey(apiKey).build() + ); + + QdrantOuterClass.HealthCheckReply healthCheckReply = client.healthCheckAsync().get(); + assertThat(healthCheckReply.getVersion()).isEqualTo("1.7.4"); + + client.close(); + } + } + + @Test + void shouldSetApiKeyUsingConfigFile() throws ExecutionException, InterruptedException { + String apiKey = UUID.randomUUID().toString(); + String configFile = "service:\n api_key: " + apiKey; + try ( + QdrantContainer qdrant = new QdrantContainer("qdrant/qdrant:v1.7.4") + .withConfigFile(Transferable.of(configFile)) + ) { + qdrant.start(); + + final QdrantClient unauthClient = new QdrantClient( + QdrantGrpcClient.newBuilder(qdrant.getHost(), qdrant.getGrpcPort(), false).build() + ); + + assertThatThrownBy(() -> unauthClient.healthCheckAsync().get()).isInstanceOf(ExecutionException.class); + + unauthClient.close(); + + final QdrantClient client = new QdrantClient( + QdrantGrpcClient.newBuilder(qdrant.getHost(), qdrant.getGrpcPort(), false).withApiKey(apiKey).build() + ); + + QdrantOuterClass.HealthCheckReply healthCheckReply = client.healthCheckAsync().get(); + assertThat(healthCheckReply.getVersion()).isEqualTo("1.7.4"); + + client.close(); + } + } +} diff --git a/modules/qdrant/src/test/resources/logback-test.xml b/modules/qdrant/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/qdrant/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/questdb/build.gradle b/modules/questdb/build.gradle new file mode 100644 index 00000000000..04b714ff783 --- /dev/null +++ b/modules/questdb/build.gradle @@ -0,0 +1,13 @@ +description = "Testcontainers :: QuestDB" + +dependencies { + api project(':testcontainers') + api project(':testcontainers-jdbc') + + testRuntimeOnly 'org.postgresql:postgresql:42.7.12' + + testImplementation project(':testcontainers-jdbc-test') + testImplementation 'org.questdb:questdb:9.2.2' + testImplementation 'org.awaitility:awaitility:4.3.0' + testImplementation 'org.apache.httpcomponents:httpclient:4.5.14' +} diff --git a/modules/questdb/src/main/java/org/testcontainers/containers/LegacyQuestDBProvider.java b/modules/questdb/src/main/java/org/testcontainers/containers/LegacyQuestDBProvider.java new file mode 100644 index 00000000000..138356f523b --- /dev/null +++ b/modules/questdb/src/main/java/org/testcontainers/containers/LegacyQuestDBProvider.java @@ -0,0 +1,15 @@ +package org.testcontainers.containers; + +@Deprecated +public class LegacyQuestDBProvider extends JdbcDatabaseContainerProvider { + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(QuestDBContainer.LEGACY_DATABASE_PROVIDER); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + return new QuestDBContainer(QuestDBContainer.DEFAULT_IMAGE_NAME.withTag(tag)); + } +} diff --git a/modules/questdb/src/main/java/org/testcontainers/containers/QuestDBContainer.java b/modules/questdb/src/main/java/org/testcontainers/containers/QuestDBContainer.java new file mode 100644 index 00000000000..86a8854e498 --- /dev/null +++ b/modules/questdb/src/main/java/org/testcontainers/containers/QuestDBContainer.java @@ -0,0 +1,97 @@ +package org.testcontainers.containers; + +import lombok.NonNull; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for QuestDB. + *

+ * Supported image: {@code questdb/questdb} + *

+ * Exposed ports: + *

    + *
  • Postgres: 8812
  • + *
  • HTTP: 9000
  • + *
  • ILP: 9009
  • + *
+ */ +public class QuestDBContainer extends JdbcDatabaseContainer { + + @Deprecated + static final String LEGACY_DATABASE_PROVIDER = "postgresql"; + + static final String DATABASE_PROVIDER = "questdb"; + + private static final String DEFAULT_DATABASE_NAME = "qdb"; + + private static final int DEFAULT_COMMIT_LAG_MS = 1000; + + private static final String DEFAULT_USERNAME = "admin"; + + private static final String DEFAULT_PASSWORD = "quest"; + + private static final Integer POSTGRES_PORT = 8812; + + private static final Integer REST_PORT = 9000; + + private static final Integer ILP_PORT = 9009; + + static final String TEST_QUERY = "SELECT 1"; + + static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("questdb/questdb"); + + public QuestDBContainer(@NonNull String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public QuestDBContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withExposedPorts(POSTGRES_PORT, REST_PORT, ILP_PORT); + addEnv("QDB_CAIRO_COMMIT_LAG", String.valueOf(DEFAULT_COMMIT_LAG_MS)); + waitingFor(Wait.forLogMessage("(?i).*A server-main enjoy.*", 1)); + } + + @Override + public String getDriverClassName() { + return "org.postgresql.Driver"; + } + + @Override + public String getJdbcUrl() { + return String.format("jdbc:postgresql://%s:%d/%s", getHost(), getMappedPort(8812), getDefaultDatabaseName()); + } + + @Override + public String getUsername() { + return DEFAULT_USERNAME; + } + + @Override + public String getPassword() { + return DEFAULT_PASSWORD; + } + + @Override + public String getTestQueryString() { + return TEST_QUERY; + } + + @Override + protected void waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this); + } + + public String getDefaultDatabaseName() { + return DEFAULT_DATABASE_NAME; + } + + public String getIlpUrl() { + return getHost() + ":" + getMappedPort(ILP_PORT); + } + + public String getHttpUrl() { + return "http://" + getHost() + ":" + getMappedPort(REST_PORT); + } +} diff --git a/modules/questdb/src/main/java/org/testcontainers/containers/QuestDBProvider.java b/modules/questdb/src/main/java/org/testcontainers/containers/QuestDBProvider.java new file mode 100644 index 00000000000..0d4e9c6eeeb --- /dev/null +++ b/modules/questdb/src/main/java/org/testcontainers/containers/QuestDBProvider.java @@ -0,0 +1,14 @@ +package org.testcontainers.containers; + +public class QuestDBProvider extends JdbcDatabaseContainerProvider { + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(QuestDBContainer.DATABASE_PROVIDER); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + return new QuestDBContainer(QuestDBContainer.DEFAULT_IMAGE_NAME.withTag(tag)); + } +} diff --git a/modules/questdb/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/questdb/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 00000000000..d92337b7ac2 --- /dev/null +++ b/modules/questdb/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1,2 @@ +org.testcontainers.containers.LegacyQuestDBProvider +org.testcontainers.containers.QuestDBProvider diff --git a/modules/questdb/src/test/java/org/testcontainers/QuestDBTestImages.java b/modules/questdb/src/test/java/org/testcontainers/QuestDBTestImages.java new file mode 100644 index 00000000000..14e42d72fae --- /dev/null +++ b/modules/questdb/src/test/java/org/testcontainers/QuestDBTestImages.java @@ -0,0 +1,7 @@ +package org.testcontainers; + +import org.testcontainers.utility.DockerImageName; + +public interface QuestDBTestImages { + DockerImageName QUESTDB_IMAGE = DockerImageName.parse("questdb/questdb:9.2.2"); +} diff --git a/modules/questdb/src/test/java/org/testcontainers/jdbc/questdb/QuestDBJDBCDriverTest.java b/modules/questdb/src/test/java/org/testcontainers/jdbc/questdb/QuestDBJDBCDriverTest.java new file mode 100644 index 00000000000..40612f05680 --- /dev/null +++ b/modules/questdb/src/test/java/org/testcontainers/jdbc/questdb/QuestDBJDBCDriverTest.java @@ -0,0 +1,18 @@ +package org.testcontainers.jdbc.questdb; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +class QuestDBJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { + { "jdbc:tc:postgresql://hostname/databasename", EnumSet.of(Options.PmdKnownBroken) }, + { "jdbc:tc:questdb://hostname/databasename", EnumSet.of(Options.PmdKnownBroken) }, + } + ); + } +} diff --git a/modules/questdb/src/test/java/org/testcontainers/junit/questdb/SimpleQuestDBTest.java b/modules/questdb/src/test/java/org/testcontainers/junit/questdb/SimpleQuestDBTest.java new file mode 100644 index 00000000000..6b0ff6e3107 --- /dev/null +++ b/modules/questdb/src/test/java/org/testcontainers/junit/questdb/SimpleQuestDBTest.java @@ -0,0 +1,74 @@ +package org.testcontainers.junit.questdb; + +import io.questdb.client.Sender; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.util.EntityUtils; +import org.junit.jupiter.api.Test; +import org.testcontainers.QuestDBTestImages; +import org.testcontainers.containers.QuestDBContainer; +import org.testcontainers.db.AbstractContainerDatabaseTest; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class SimpleQuestDBTest extends AbstractContainerDatabaseTest { + + private static final String TABLE_NAME = "mytable"; + + @Test + void testSimple() throws SQLException { + try ( // container { + QuestDBContainer questDB = new QuestDBContainer("questdb/questdb:9.2.2") + // } + ) { + questDB.start(); + + ResultSet resultSet = performQuery(questDB, questDB.getTestQueryString()); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).as("A basic SELECT query succeeds").isEqualTo(1); + } + } + + @Test + void testRest() throws IOException { + try (QuestDBContainer questdb = new QuestDBContainer(QuestDBTestImages.QUESTDB_IMAGE)) { + questdb.start(); + populateByInfluxLineProtocol(questdb, 1_000); + try (CloseableHttpClient client = HttpClientBuilder.create().build()) { + String encodedSql = URLEncoder.encode("select * from " + TABLE_NAME, "UTF-8"); + HttpGet httpGet = new HttpGet(questdb.getHttpUrl() + "/exec?query=" + encodedSql); + await() + .untilAsserted(() -> { + try (CloseableHttpResponse response = client.execute(httpGet)) { + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + String json = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + assertThat(json.contains("\"count\":1000")).isTrue(); + } + }); + } + } + } + + private static void populateByInfluxLineProtocol(QuestDBContainer questdb, int rowCount) { + try (Sender sender = Sender.builder(Sender.Transport.TCP).address(questdb.getIlpUrl()).build()) { + for (int i = 0; i < rowCount; i++) { + sender + .table(TABLE_NAME) + .symbol("sym", "sym1" + i) + .stringColumn("str", "str1" + i) + .longColumn("long", i) + .atNow(); + } + } + } +} diff --git a/modules/questdb/src/test/resources/logback-test.xml b/modules/questdb/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/questdb/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/r2dbc/build.gradle b/modules/r2dbc/build.gradle new file mode 100644 index 00000000000..9c93885fd6f --- /dev/null +++ b/modules/r2dbc/build.gradle @@ -0,0 +1,17 @@ +plugins { + id "java-test-fixtures" +} + +description = "Testcontainers :: R2DBC" + +dependencies { + api project(':testcontainers') + api 'io.r2dbc:r2dbc-spi:0.9.0.RELEASE' + + testImplementation 'io.r2dbc:r2dbc-postgresql:0.8.13.RELEASE' + testImplementation project(':testcontainers-postgresql') + + testFixturesImplementation 'io.projectreactor:reactor-core:3.8.6' + testFixturesImplementation 'org.assertj:assertj-core:3.27.7' + testFixturesImplementation 'org.junit.jupiter:junit-jupiter:5.14.3' +} diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/CancellableSubscription.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/CancellableSubscription.java new file mode 100644 index 00000000000..3f4fd7e4b1e --- /dev/null +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/CancellableSubscription.java @@ -0,0 +1,22 @@ +package org.testcontainers.r2dbc; + +import org.reactivestreams.Subscription; + +import java.util.concurrent.atomic.AtomicBoolean; + +class CancellableSubscription implements Subscription { + + private final AtomicBoolean cancelled = new AtomicBoolean(); + + @Override + public void request(long n) {} + + @Override + public void cancel() { + cancelled.set(true); + } + + public boolean isCancelled() { + return cancelled.get(); + } +} diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/ConnectionPublisher.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/ConnectionPublisher.java new file mode 100644 index 00000000000..d1b1c0e43f3 --- /dev/null +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/ConnectionPublisher.java @@ -0,0 +1,159 @@ +package org.testcontainers.r2dbc; + +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactory; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +/** + * Design notes: + * - ConnectionPublisher is Mono-like (0..1), the request amount is ignored + * - given the testing nature, the performance requirements are less strict + * - "synchronized" is used to avoid races + * - Reactive Streams spec violations are not checked (e.g. non-positive request) + */ +class ConnectionPublisher implements Publisher { + + private final Supplier> futureSupplier; + + ConnectionPublisher(Supplier> futureSupplier) { + this.futureSupplier = futureSupplier; + } + + @Override + public void subscribe(Subscriber actual) { + actual.onSubscribe(new StateMachineSubscription(actual)); + } + + private class StateMachineSubscription implements Subscription { + + private final Subscriber actual; + + Subscription subscriptionState; + + StateMachineSubscription(Subscriber actual) { + this.actual = actual; + subscriptionState = new WaitRequestSubscriptionState(); + } + + @Override + public synchronized void request(long n) { + subscriptionState.request(n); + } + + @Override + public synchronized void cancel() { + subscriptionState.cancel(); + } + + synchronized void transitionTo(SubscriptionState newState) { + subscriptionState = newState; + newState.enter(); + } + + abstract class SubscriptionState implements Subscription { + + void enter() {} + } + + class WaitRequestSubscriptionState extends SubscriptionState { + + @Override + public void request(long n) { + transitionTo(new WaitFutureCompletionSubscriptionState()); + } + + @Override + public void cancel() {} + } + + class WaitFutureCompletionSubscriptionState extends SubscriptionState { + + private CompletableFuture future; + + @Override + void enter() { + this.future = futureSupplier.get(); + + future.whenComplete((connectionFactory, e) -> { + if (e != null) { + actual.onSubscribe(EmptySubscription.INSTANCE); + actual.onError(e); + return; + } + + Publisher publisher = connectionFactory.create(); + transitionTo(new ProxySubscriptionState(publisher)); + }); + } + + @Override + public void request(long n) {} + + @Override + public void cancel() { + future.cancel(true); + } + } + + class ProxySubscriptionState extends SubscriptionState implements Subscriber { + + private final Publisher publisher; + + private Subscription s; + + private boolean cancelled = false; + + ProxySubscriptionState(Publisher publisher) { + this.publisher = publisher; + } + + @Override + void enter() { + publisher.subscribe(this); + } + + @Override + public void request(long n) { + // Ignore + } + + @Override + public synchronized void cancel() { + cancelled = true; + if (s != null) { + s.cancel(); + } + } + + @Override + public synchronized void onSubscribe(Subscription s) { + this.s = s; + if (!cancelled) { + s.request(1); + } else { + s.cancel(); + } + } + + @Override + public void onNext(Connection connection) { + actual.onNext(connection); + } + + @Override + public void onError(Throwable t) { + actual.onError(t); + } + + @Override + public void onComplete() { + actual.onComplete(); + } + } + } +} diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/EmptySubscription.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/EmptySubscription.java new file mode 100644 index 00000000000..5cd2ffbd894 --- /dev/null +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/EmptySubscription.java @@ -0,0 +1,13 @@ +package org.testcontainers.r2dbc; + +import org.reactivestreams.Subscription; + +enum EmptySubscription implements Subscription { + INSTANCE; + + @Override + public void request(long n) {} + + @Override + public void cancel() {} +} diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/Hidden.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/Hidden.java new file mode 100644 index 00000000000..327ad6ab9ca --- /dev/null +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/Hidden.java @@ -0,0 +1,71 @@ +package org.testcontainers.r2dbc; + +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryOptions; +import io.r2dbc.spi.ConnectionFactoryProvider; + +/** + * Hide inner classes that must be public due to the way {@link java.util.ServiceLoader} works + */ +class Hidden { + + public static final class TestcontainersR2DBCConnectionFactoryProvider implements ConnectionFactoryProvider { + + public static final String DRIVER = "tc"; + + @Override + public ConnectionFactory create(ConnectionFactoryOptions options) { + options = sanitize(options); + options = removeProxying(options); + + return new TestcontainersR2DBCConnectionFactory(options); + } + + private ConnectionFactoryOptions sanitize(ConnectionFactoryOptions options) { + ConnectionFactoryOptions.Builder builder = options.mutate(); + + Object reusable = options.getValue(R2DBCDatabaseContainerProvider.REUSABLE_OPTION); + if (reusable instanceof String) { + builder.option(R2DBCDatabaseContainerProvider.REUSABLE_OPTION, Boolean.valueOf((String) reusable)); + } + return builder.build(); + } + + private ConnectionFactoryOptions removeProxying(ConnectionFactoryOptions options) { + // To delegate to the next factory provider, inspect the PROTOCOL and convert it to the next DRIVER and PROTOCOL values. + // + // example: + // | Property | Input | Output | + // |----------|-----------------|--------------| + // | DRIVER | tc | postgres | + // | PROTOCOL | postgres | | + + String protocol = (String) options.getRequiredValue(ConnectionFactoryOptions.PROTOCOL); + if (protocol.trim().length() == 0) { + throw new IllegalArgumentException("Invalid protocol: " + protocol); + } + String[] protocols = protocol.split(":", 2); + String driverDelegate = protocols[0]; + + // when protocol does NOT contain COLON, the length becomes 1 + String protocolDelegate = protocols.length == 2 ? protocols[1] : ""; + + return ConnectionFactoryOptions + .builder() + .from(options) + .option(ConnectionFactoryOptions.DRIVER, driverDelegate) + .option(ConnectionFactoryOptions.PROTOCOL, protocolDelegate) + .build(); + } + + @Override + public boolean supports(ConnectionFactoryOptions options) { + return DRIVER.equals(options.getValue(ConnectionFactoryOptions.DRIVER)); + } + + @Override + public String getDriver() { + return DRIVER; + } + } +} diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainer.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainer.java new file mode 100644 index 00000000000..8bf0401fe69 --- /dev/null +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainer.java @@ -0,0 +1,8 @@ +package org.testcontainers.r2dbc; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.lifecycle.Startable; + +public interface R2DBCDatabaseContainer extends Startable { + ConnectionFactoryOptions configure(ConnectionFactoryOptions options); +} diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainerProvider.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainerProvider.java new file mode 100644 index 00000000000..ded7aaa9ffd --- /dev/null +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainerProvider.java @@ -0,0 +1,34 @@ +package org.testcontainers.r2dbc; + +import io.r2dbc.spi.ConnectionFactories; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryMetadata; +import io.r2dbc.spi.ConnectionFactoryOptions; +import io.r2dbc.spi.Option; +import org.testcontainers.DockerClientFactory; + +import javax.annotation.Nullable; + +public interface R2DBCDatabaseContainerProvider { + Option REUSABLE_OPTION = Option.valueOf("TC_REUSABLE"); + + Option IMAGE_TAG_OPTION = Option.valueOf("TC_IMAGE_TAG"); + + boolean supports(ConnectionFactoryOptions options); + + R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options); + + @Nullable + default ConnectionFactoryMetadata getMetadata(ConnectionFactoryOptions options) { + ConnectionFactoryOptions.Builder builder = options.mutate(); + if (!options.hasOption(ConnectionFactoryOptions.HOST)) { + builder.option(ConnectionFactoryOptions.HOST, DockerClientFactory.instance().dockerHostIpAddress()); + } + if (!options.hasOption(ConnectionFactoryOptions.PORT)) { + builder.option(ConnectionFactoryOptions.PORT, 65535); + } + + ConnectionFactory connectionFactory = ConnectionFactories.find(builder.build()); + return connectionFactory != null ? connectionFactory.getMetadata() : null; + } +} diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactory.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactory.java new file mode 100644 index 00000000000..bada076eec9 --- /dev/null +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactory.java @@ -0,0 +1,104 @@ +package org.testcontainers.r2dbc; + +import io.r2dbc.spi.Closeable; +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactories; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryMetadata; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.reactivestreams.Publisher; +import org.testcontainers.lifecycle.Startable; + +import java.util.ServiceLoader; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.StreamSupport; + +class TestcontainersR2DBCConnectionFactory implements ConnectionFactory, Closeable { + + private static final AtomicLong THREAD_COUNT = new AtomicLong(); + + private static final Executor EXECUTOR = Executors.newCachedThreadPool(r -> { + Thread thread = new Thread(r); + thread.setName("testcontainers-r2dbc-" + THREAD_COUNT.getAndIncrement()); + thread.setDaemon(true); + return thread; + }); + + private final ConnectionFactoryOptions options; + + private final R2DBCDatabaseContainerProvider containerProvider; + + private CompletableFuture future; + + TestcontainersR2DBCConnectionFactory(ConnectionFactoryOptions options) { + this.options = options; + + containerProvider = + StreamSupport + .stream(ServiceLoader.load(R2DBCDatabaseContainerProvider.class).spliterator(), false) + .filter(it -> it.supports(options)) + .findAny() + .orElseThrow(() -> new IllegalArgumentException("Missing provider for " + options)); + } + + @Override + public Publisher create() { + return new ConnectionPublisher(() -> { + if (future == null) { + synchronized (this) { + if (future == null) { + future = + CompletableFuture.supplyAsync( + () -> { + R2DBCDatabaseContainer container = containerProvider.createContainer(options); + container.start(); + return container; + }, + EXECUTOR + ); + } + } + } + return future.thenApply(it -> { + return ConnectionFactories.find(it.configure(options)); + }); + }); + } + + @Override + public ConnectionFactoryMetadata getMetadata() { + return containerProvider.getMetadata(options); + } + + @Override + public Publisher close() { + return s -> { + CompletableFuture futureRef; + synchronized (this) { + futureRef = this.future; + this.future = null; + } + + CancellableSubscription subscription = new CancellableSubscription(); + s.onSubscribe(subscription); + + if (futureRef == null) { + if (!subscription.isCancelled()) { + s.onComplete(); + } + } else { + futureRef.thenAcceptAsync(Startable::stop, EXECUTOR); + + EXECUTOR.execute(() -> { + futureRef.cancel(true); + if (!subscription.isCancelled()) { + s.onComplete(); + } + }); + } + }; + } +} diff --git a/modules/r2dbc/src/main/resources/META-INF/services/io.r2dbc.spi.ConnectionFactoryProvider b/modules/r2dbc/src/main/resources/META-INF/services/io.r2dbc.spi.ConnectionFactoryProvider new file mode 100644 index 00000000000..23f1702c57a --- /dev/null +++ b/modules/r2dbc/src/main/resources/META-INF/services/io.r2dbc.spi.ConnectionFactoryProvider @@ -0,0 +1 @@ +org.testcontainers.r2dbc.Hidden$TestcontainersR2DBCConnectionFactoryProvider diff --git a/modules/r2dbc/src/test/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactoryTest.java b/modules/r2dbc/src/test/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactoryTest.java new file mode 100644 index 00000000000..7a76b436e22 --- /dev/null +++ b/modules/r2dbc/src/test/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactoryTest.java @@ -0,0 +1,76 @@ +package org.testcontainers.r2dbc; + +import io.r2dbc.postgresql.api.PostgresqlException; +import io.r2dbc.spi.Closeable; +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactories; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.Result; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TestcontainersR2DBCConnectionFactoryTest { + + @Test + void failsOnUnknownProvider() { + String nonExistingProvider = UUID.randomUUID().toString(); + assertThatThrownBy(() -> { + ConnectionFactories.get(String.format("r2dbc:tc:%s:///db", nonExistingProvider)); + }) + .hasMessageContaining("Missing provider") + .hasMessageContaining(nonExistingProvider); + } + + @Test + void reusesUntilConnectionFactoryIsClosed() { + String url = "r2dbc:tc:postgresql:///db?TC_IMAGE_TAG=10-alpine"; + ConnectionFactory connectionFactory = ConnectionFactories.get(url); + + Integer updated = Flux + .usingWhen( + connectionFactory.create(), + connection -> { + return Mono + .from(connection.createStatement("CREATE TABLE test(id integer PRIMARY KEY)").execute()) + .thenMany(connection.createStatement("INSERT INTO test(id) VALUES(123)").execute()) + .flatMap(Result::getRowsUpdated); + }, + Connection::close + ) + .blockFirst(); + + assertThat(updated).isEqualTo(1); + + Flux select = Flux.usingWhen( + Flux.defer(connectionFactory::create), + connection -> { + return Flux + .from(connection.createStatement("SELECT COUNT(*) FROM test").execute()) + .flatMap(it -> it.map((row, meta) -> (Long) row.get(0))); + }, + Connection::close + ); + + Long rows = select.blockFirst(); + + assertThat(rows).isEqualTo(1); + + close(connectionFactory); + + assertThatThrownBy(select::blockFirst) + .isInstanceOf(PostgresqlException.class) + // relation "X" does not exists + // https://github.com/postgres/postgres/blob/REL_10_0/src/backend/utils/errcodes.txt#L349 + .returns("42P01", e -> ((PostgresqlException) e).getErrorDetails().getCode()); + } + + private static void close(ConnectionFactory connectionFactory) { + Mono.from(((Closeable) connectionFactory).close()).block(); + } +} diff --git a/modules/r2dbc/src/test/resources/logback-test.xml b/modules/r2dbc/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/r2dbc/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/r2dbc/src/testFixtures/java/org/testcontainers/r2dbc/AbstractR2DBCDatabaseContainerTest.java b/modules/r2dbc/src/testFixtures/java/org/testcontainers/r2dbc/AbstractR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..ed0a7fba70f --- /dev/null +++ b/modules/r2dbc/src/testFixtures/java/org/testcontainers/r2dbc/AbstractR2DBCDatabaseContainerTest.java @@ -0,0 +1,74 @@ +package org.testcontainers.r2dbc; + +import io.r2dbc.spi.Closeable; +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactories; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryMetadata; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +public abstract class AbstractR2DBCDatabaseContainerTest> { + + protected abstract ConnectionFactoryOptions getOptions(T container); + + protected abstract String createR2DBCUrl(); + + protected String query() { + return "SELECT %d"; + } + + protected String createTestQuery(int result) { + return String.format(query(), result); + } + + @Test + void testGetOptions() { + try (T container = createContainer()) { + container.start(); + + ConnectionFactory connectionFactory = ConnectionFactories.get(getOptions(container)); + runTestQuery(connectionFactory); + } + } + + @Test + void testUrlSupport() { + ConnectionFactory connectionFactory = ConnectionFactories.get(createR2DBCUrl()); + runTestQuery(connectionFactory); + } + + @Test + void testGetMetadata() { + ConnectionFactory connectionFactory = ConnectionFactories.get(createR2DBCUrl()); + ConnectionFactoryMetadata metadata = connectionFactory.getMetadata(); + assertThat(metadata).isNotNull(); + } + + protected abstract T createContainer(); + + protected void runTestQuery(ConnectionFactory connectionFactory) { + try { + int expected = 42; + Number result = Flux + .usingWhen( + connectionFactory.create(), + connection -> connection.createStatement(createTestQuery(expected)).execute(), + Connection::close + ) + .flatMap(it -> it.map((row, meta) -> (Number) row.get(0))) + .blockFirst(); + + assertThat(result).isNotNull().returns(expected, Number::intValue); + } finally { + if (connectionFactory instanceof Closeable) { + Mono.from(((Closeable) connectionFactory).close()).block(); + } + } + } +} diff --git a/modules/rabbitmq/build.gradle b/modules/rabbitmq/build.gradle new file mode 100644 index 00000000000..1b9cd99168b --- /dev/null +++ b/modules/rabbitmq/build.gradle @@ -0,0 +1,8 @@ +description = "Testcontainers :: RabbitMQ" + +dependencies { + api project(":testcontainers") + + testImplementation 'com.rabbitmq:amqp-client:5.33.0' + compileOnly 'org.jetbrains:annotations:26.1.0' +} diff --git a/modules/rabbitmq/src/main/java/org/testcontainers/containers/RabbitMQContainer.java b/modules/rabbitmq/src/main/java/org/testcontainers/containers/RabbitMQContainer.java new file mode 100644 index 00000000000..6db9f6b4cf0 --- /dev/null +++ b/modules/rabbitmq/src/main/java/org/testcontainers/containers/RabbitMQContainer.java @@ -0,0 +1,724 @@ +package org.testcontainers.containers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.dockerjava.api.command.InspectContainerResponse; +import org.jetbrains.annotations.NotNull; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Testcontainers implementation for RabbitMQ. + *

+ * Supported image: {@code rabbitmq} + *

+ * Exposed ports: + *

    + *
  • 5671 (AMQPS)
  • + *
  • 5672 (AMQP)
  • + *
  • 15671 (HTTPS)
  • + *
  • 15672 (HTTP)
  • + *
+ * + * @deprecated use {@link org.testcontainers.rabbitmq.RabbitMQContainer} instead. + */ +@Deprecated +public class RabbitMQContainer extends GenericContainer { + + /** + * The image defaults to the official RabbitMQ image: RabbitMQ. + */ + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("rabbitmq"); + + private static final String DEFAULT_TAG = "3.7.25-management-alpine"; + + private static final int DEFAULT_AMQP_PORT = 5672; + + private static final int DEFAULT_AMQPS_PORT = 5671; + + private static final int DEFAULT_HTTPS_PORT = 15671; + + private static final int DEFAULT_HTTP_PORT = 15672; + + private String adminPassword = "guest"; + + private String adminUsername = "guest"; + + private final List> values = new ArrayList<>(); + + /** + * Creates a RabbitMQ container using the official RabbitMQ docker image. + * @deprecated use {@link #RabbitMQContainer(DockerImageName)} instead + */ + @Deprecated + public RabbitMQContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + /** + * Creates a RabbitMQ container using a specific docker image. + * + * @param dockerImageName The docker image to use. + */ + public RabbitMQContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public RabbitMQContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + addExposedPorts(DEFAULT_AMQP_PORT, DEFAULT_AMQPS_PORT, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT); + + waitingFor(Wait.forLogMessage(".*Server startup complete.*", 1)); + } + + @Override + protected void configure() { + if (this.adminUsername != null) { + addEnv("RABBITMQ_DEFAULT_USER", this.adminUsername); + } + if (this.adminPassword != null) { + addEnv("RABBITMQ_DEFAULT_PASS", this.adminPassword); + } + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + values.forEach(command -> { + try { + ExecResult execResult = execInContainer(command.toArray(new String[0])); + if (execResult.getExitCode() != 0) { + logger().error("Could not execute command {}: {}", command, execResult.getStderr()); + } + } catch (IOException | InterruptedException e) { + logger().error("Could not execute command {}: {}", command, e.getMessage()); + } + }); + } + + /** + * @return The admin password for the admin account + */ + public String getAdminPassword() { + return this.adminPassword; + } + + /** + * @return The admin user for the admin account + */ + public String getAdminUsername() { + return this.adminUsername; + } + + public Integer getAmqpPort() { + return getMappedPort(DEFAULT_AMQP_PORT); + } + + public Integer getAmqpsPort() { + return getMappedPort(DEFAULT_AMQPS_PORT); + } + + public Integer getHttpsPort() { + return getMappedPort(DEFAULT_HTTPS_PORT); + } + + public Integer getHttpPort() { + return getMappedPort(DEFAULT_HTTP_PORT); + } + + /** + * @return AMQP URL for use with AMQP clients. + */ + public String getAmqpUrl() { + return "amqp://" + getHost() + ":" + getAmqpPort(); + } + + /** + * @return AMQPS URL for use with AMQPS clients. + */ + public String getAmqpsUrl() { + return "amqps://" + getHost() + ":" + getAmqpsPort(); + } + + /** + * @return URL of the HTTP management endpoint. + */ + public String getHttpUrl() { + return "http://" + getHost() + ":" + getHttpPort(); + } + + /** + * @return URL of the HTTPS management endpoint. + */ + public String getHttpsUrl() { + return "https://" + getHost() + ":" + getHttpsPort(); + } + + /** + * Sets the user for the admin (default is
guest
) + * + * @param adminUsername The admin user. + * @return This container. + */ + public RabbitMQContainer withAdminUser(final String adminUsername) { + this.adminUsername = adminUsername; + return this; + } + + /** + * Sets the password for the admin (default is
guest
) + * + * @param adminPassword The admin password. + * @return This container. + */ + public RabbitMQContainer withAdminPassword(final String adminPassword) { + this.adminPassword = adminPassword; + return this; + } + + public enum SslVerification { + VERIFY_NONE("verify_none"), + VERIFY_PEER("verify_peer"); + + SslVerification(String value) { + this.value = value; + } + + private final String value; + } + + public RabbitMQContainer withSSL( + final MountableFile keyFile, + final MountableFile certFile, + final MountableFile caFile, + final SslVerification verify, + boolean failIfNoCert, + int verificationDepth + ) { + return withSSL(keyFile, certFile, caFile, verify, failIfNoCert) + .withEnv("RABBITMQ_SSL_DEPTH", String.valueOf(verificationDepth)); + } + + public RabbitMQContainer withSSL( + final MountableFile keyFile, + final MountableFile certFile, + final MountableFile caFile, + final SslVerification verify, + boolean failIfNoCert + ) { + return withSSL(keyFile, certFile, caFile, verify) + .withEnv("RABBITMQ_SSL_FAIL_IF_NO_PEER_CERT", String.valueOf(failIfNoCert)); + } + + public RabbitMQContainer withSSL( + final MountableFile keyFile, + final MountableFile certFile, + final MountableFile caFile, + final SslVerification verify + ) { + return withEnv("RABBITMQ_SSL_CACERTFILE", "/etc/rabbitmq/ca_cert.pem") + .withEnv("RABBITMQ_SSL_CERTFILE", "/etc/rabbitmq/rabbitmq_cert.pem") + .withEnv("RABBITMQ_SSL_KEYFILE", "/etc/rabbitmq/rabbitmq_key.pem") + .withEnv("RABBITMQ_SSL_VERIFY", verify.value) + .withCopyFileToContainer(certFile, "/etc/rabbitmq/rabbitmq_cert.pem") + .withCopyFileToContainer(caFile, "/etc/rabbitmq/ca_cert.pem") + .withCopyFileToContainer(keyFile, "/etc/rabbitmq/rabbitmq_key.pem"); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withPluginsEnabled(String... pluginNames) { + List command = new ArrayList<>(Arrays.asList("rabbitmq-plugins", "enable")); + command.addAll(Arrays.asList(pluginNames)); + values.add(command); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withBinding(String source, String destination) { + values.add( + Arrays.asList("rabbitmqadmin", "declare", "binding", "source=" + source, "destination=" + destination) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withBinding(String vhost, String source, String destination) { + values.add( + Arrays.asList( + "rabbitmqadmin", + "--vhost=" + vhost, + "declare", + "binding", + "source=" + source, + "destination=" + destination + ) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withBinding( + String source, + String destination, + Map arguments, + String routingKey, + String destinationType + ) { + values.add( + Arrays.asList( + "rabbitmqadmin", + "declare", + "binding", + "source=" + source, + "destination=" + destination, + "routing_key=" + routingKey, + "destination_type=" + destinationType, + "arguments=" + toJson(arguments) + ) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withBinding( + String vhost, + String source, + String destination, + Map arguments, + String routingKey, + String destinationType + ) { + values.add( + Arrays.asList( + "rabbitmqadmin", + "--vhost=" + vhost, + "declare", + "binding", + "source=" + source, + "destination=" + destination, + "routing_key=" + routingKey, + "destination_type=" + destinationType, + "arguments=" + toJson(arguments) + ) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withParameter(String component, String name, String value) { + values.add( + Arrays.asList( + "rabbitmqadmin", + "declare", + "parameter", + "component=" + component, + "name=" + name, + "value=" + value + ) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withPermission(String vhost, String user, String configure, String write, String read) { + values.add( + Arrays.asList( + "rabbitmqadmin", + "declare", + "permission", + "vhost=" + vhost, + "user=" + user, + "configure=" + configure, + "write=" + write, + "read=" + read + ) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withUser(String name, String password) { + values.add(Arrays.asList("rabbitmqadmin", "declare", "user", "name=" + name, "password=" + password, "tags=")); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withUser(String name, String password, Set tags) { + values.add( + Arrays.asList( + "rabbitmqadmin", + "declare", + "user", + "name=" + name, + "password=" + password, + "tags=" + String.join(",", tags) + ) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withPolicy(String name, String pattern, Map definition) { + values.add( + Arrays.asList( + "rabbitmqadmin", + "declare", + "policy", + "name=" + name, + "pattern=" + pattern, + "definition=" + toJson(definition) + ) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withPolicy(String vhost, String name, String pattern, Map definition) { + values.add( + Arrays.asList( + "rabbitmqadmin", + "declare", + "policy", + "--vhost=" + vhost, + "name=" + name, + "pattern=" + pattern, + "definition=" + toJson(definition) + ) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withPolicy( + String name, + String pattern, + Map definition, + int priority, + String applyTo + ) { + values.add( + Arrays.asList( + "rabbitmqadmin", + "declare", + "policy", + "name=" + name, + "pattern=" + pattern, + "priority=" + priority, + "apply-to=" + applyTo, + "definition=" + toJson(definition) + ) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withOperatorPolicy(String name, String pattern, Map definition) { + values.add( + new ArrayList<>( + Arrays.asList( + "rabbitmqadmin", + "declare", + "operator_policy", + "name=" + name, + "pattern=" + pattern, + "definition=" + toJson(definition) + ) + ) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withOperatorPolicy( + String name, + String pattern, + Map definition, + int priority, + String applyTo + ) { + values.add( + Arrays.asList( + "rabbitmqadmin", + "declare", + "operator_policy", + "name=" + name, + "pattern=" + pattern, + "priority=" + priority, + "apply-to=" + applyTo, + "definition=" + toJson(definition) + ) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withVhost(String name) { + values.add(Arrays.asList("rabbitmqadmin", "declare", "vhost", "name=" + name)); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withVhost(String name, boolean tracing) { + values.add(Arrays.asList("rabbitmqadmin", "declare", "vhost", "name=" + name, "tracing=" + tracing)); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withVhostLimit(String vhost, String name, int value) { + values.add( + Arrays.asList("rabbitmqadmin", "declare", "vhost_limit", "vhost=" + vhost, "name=" + name, "value=" + value) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withQueue(String name) { + values.add(Arrays.asList("rabbitmqadmin", "declare", "queue", "name=" + name)); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withQueue(String vhost, String name) { + values.add(Arrays.asList("rabbitmqadmin", "--vhost=" + vhost, "declare", "queue", "name=" + name)); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withQueue( + String name, + boolean autoDelete, + boolean durable, + Map arguments + ) { + values.add( + Arrays.asList( + "rabbitmqadmin", + "declare", + "queue", + "name=" + name, + "auto_delete=" + autoDelete, + "durable=" + durable, + "arguments=" + toJson(arguments) + ) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withQueue( + String vhost, + String name, + boolean autoDelete, + boolean durable, + Map arguments + ) { + values.add( + Arrays.asList( + "rabbitmqadmin", + "--vhost=" + vhost, + "declare", + "queue", + "name=" + name, + "auto_delete=" + autoDelete, + "durable=" + durable, + "arguments=" + toJson(arguments) + ) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withExchange(String name, String type) { + values.add(Arrays.asList("rabbitmqadmin", "declare", "exchange", "name=" + name, "type=" + type)); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withExchange(String vhost, String name, String type) { + values.add( + Arrays.asList("rabbitmqadmin", "--vhost=" + vhost, "declare", "exchange", "name=" + name, "type=" + type) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withExchange( + String name, + String type, + boolean autoDelete, + boolean internal, + boolean durable, + Map arguments + ) { + values.add( + Arrays.asList( + "rabbitmqadmin", + "declare", + "exchange", + "name=" + name, + "type=" + type, + "auto_delete=" + autoDelete, + "internal=" + internal, + "durable=" + durable, + "arguments=" + toJson(arguments) + ) + ); + return self(); + } + + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated + public RabbitMQContainer withExchange( + String vhost, + String name, + String type, + boolean autoDelete, + boolean internal, + boolean durable, + Map arguments + ) { + values.add( + Arrays.asList( + "rabbitmqadmin", + "--vhost=" + vhost, + "declare", + "exchange", + "name=" + name, + "type=" + type, + "auto_delete=" + autoDelete, + "internal=" + internal, + "durable=" + durable, + "arguments=" + toJson(arguments) + ) + ); + return self(); + } + + /** + * Overwrites the default RabbitMQ configuration file with the supplied one. + * + * @param rabbitMQConf The rabbitmq.conf file to use (in sysctl format, don't forget empty line in the end of file) + * @return This container. + */ + public RabbitMQContainer withRabbitMQConfig(MountableFile rabbitMQConf) { + return withRabbitMQConfigSysctl(rabbitMQConf); + } + + /** + * Overwrites the default RabbitMQ configuration file with the supplied one. + * + * This function doesn't work with RabbitMQ < 3.7. + * + * This function and the Sysctl format is recommended for RabbitMQ >= 3.7 + * + * @param rabbitMQConf The rabbitmq.config file to use (in sysctl format, don't forget empty line in the end of file) + * @return This container. + */ + public RabbitMQContainer withRabbitMQConfigSysctl(MountableFile rabbitMQConf) { + withEnv("RABBITMQ_CONFIG_FILE", "/etc/rabbitmq/rabbitmq-custom.conf"); + return withCopyFileToContainer(rabbitMQConf, "/etc/rabbitmq/rabbitmq-custom.conf"); + } + + /** + * Overwrites the default RabbitMQ configuration file with the supplied one. + * + * @param rabbitMQConf The rabbitmq.config file to use (in erlang format) + * @return This container. + */ + public RabbitMQContainer withRabbitMQConfigErlang(MountableFile rabbitMQConf) { + withEnv("RABBITMQ_CONFIG_FILE", "/etc/rabbitmq/rabbitmq-custom.config"); + return withCopyFileToContainer(rabbitMQConf, "/etc/rabbitmq/rabbitmq-custom.config"); + } + + @NotNull + private String toJson(Map arguments) { + try { + return new ObjectMapper().writeValueAsString(arguments); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to convert arguments into json: " + e.getMessage(), e); + } + } +} diff --git a/modules/rabbitmq/src/main/java/org/testcontainers/rabbitmq/RabbitMQContainer.java b/modules/rabbitmq/src/main/java/org/testcontainers/rabbitmq/RabbitMQContainer.java new file mode 100644 index 00000000000..5b6d79d73c4 --- /dev/null +++ b/modules/rabbitmq/src/main/java/org/testcontainers/rabbitmq/RabbitMQContainer.java @@ -0,0 +1,204 @@ +package org.testcontainers.rabbitmq; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Testcontainers implementation for RabbitMQ. + *

+ * Supported image: {@code rabbitmq} + *

+ * Exposed ports: + *

    + *
  • 5671 (AMQPS)
  • + *
  • 5672 (AMQP)
  • + *
  • 15671 (HTTPS)
  • + *
  • 15672 (HTTP)
  • + *
+ */ +public class RabbitMQContainer extends GenericContainer { + + /** + * The image defaults to the official RabbitMQ image: RabbitMQ. + */ + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("rabbitmq"); + + private static final int DEFAULT_AMQP_PORT = 5672; + + private static final int DEFAULT_AMQPS_PORT = 5671; + + private static final int DEFAULT_HTTPS_PORT = 15671; + + private static final int DEFAULT_HTTP_PORT = 15672; + + private String adminPassword = "guest"; + + private String adminUsername = "guest"; + + private final List> values = new ArrayList<>(); + + /** + * Creates a RabbitMQ container using a specific docker image. + * + * @param dockerImageName The docker image to use. + */ + public RabbitMQContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public RabbitMQContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + addExposedPorts(DEFAULT_AMQP_PORT, DEFAULT_AMQPS_PORT, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT); + + waitingFor(Wait.forLogMessage(".*Server startup complete.*", 1)); + } + + @Override + protected void configure() { + if (this.adminUsername != null) { + addEnv("RABBITMQ_DEFAULT_USER", this.adminUsername); + } + if (this.adminPassword != null) { + addEnv("RABBITMQ_DEFAULT_PASS", this.adminPassword); + } + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + values.forEach(command -> { + try { + ExecResult execResult = execInContainer(command.toArray(new String[0])); + if (execResult.getExitCode() != 0) { + logger().error("Could not execute command {}: {}", command, execResult.getStderr()); + } + } catch (IOException | InterruptedException e) { + logger().error("Could not execute command {}: {}", command, e.getMessage()); + } + }); + } + + /** + * @return The admin password for the admin account + */ + public String getAdminPassword() { + return this.adminPassword; + } + + /** + * @return The admin user for the admin account + */ + public String getAdminUsername() { + return this.adminUsername; + } + + public Integer getAmqpPort() { + return getMappedPort(DEFAULT_AMQP_PORT); + } + + public Integer getAmqpsPort() { + return getMappedPort(DEFAULT_AMQPS_PORT); + } + + public Integer getHttpsPort() { + return getMappedPort(DEFAULT_HTTPS_PORT); + } + + public Integer getHttpPort() { + return getMappedPort(DEFAULT_HTTP_PORT); + } + + /** + * @return AMQP URL for use with AMQP clients. + */ + public String getAmqpUrl() { + return "amqp://" + getHost() + ":" + getAmqpPort(); + } + + /** + * @return AMQPS URL for use with AMQPS clients. + */ + public String getAmqpsUrl() { + return "amqps://" + getHost() + ":" + getAmqpsPort(); + } + + /** + * @return URL of the HTTP management endpoint. + */ + public String getHttpUrl() { + return "http://" + getHost() + ":" + getHttpPort(); + } + + /** + * @return URL of the HTTPS management endpoint. + */ + public String getHttpsUrl() { + return "https://" + getHost() + ":" + getHttpsPort(); + } + + /** + * Sets the user for the admin (default is
guest
) + * + * @param adminUsername The admin user. + * @return This container. + */ + public RabbitMQContainer withAdminUser(final String adminUsername) { + this.adminUsername = adminUsername; + return this; + } + + /** + * Sets the password for the admin (default is
guest
) + * + * @param adminPassword The admin password. + * @return This container. + */ + public RabbitMQContainer withAdminPassword(final String adminPassword) { + this.adminPassword = adminPassword; + return this; + } + + /** + * Overwrites the default RabbitMQ configuration file with the supplied one. + * + * @param rabbitMQConf The rabbitmq.conf file to use (in sysctl format, don't forget empty line in the end of file) + * @return This container. + */ + public RabbitMQContainer withRabbitMQConfig(MountableFile rabbitMQConf) { + return withRabbitMQConfigSysctl(rabbitMQConf); + } + + /** + * Overwrites the default RabbitMQ configuration file with the supplied one. + * + * This function doesn't work with RabbitMQ < 3.7. + * + * This function and the Sysctl format is recommended for RabbitMQ >= 3.7 + * + * @param rabbitMQConf The rabbitmq.config file to use (in sysctl format, don't forget empty line in the end of file) + * @return This container. + */ + public RabbitMQContainer withRabbitMQConfigSysctl(MountableFile rabbitMQConf) { + withEnv("RABBITMQ_CONFIG_FILE", "/etc/rabbitmq/rabbitmq-custom.conf"); + return withCopyFileToContainer(rabbitMQConf, "/etc/rabbitmq/rabbitmq-custom.conf"); + } + + /** + * Overwrites the default RabbitMQ configuration file with the supplied one. + * + * @param rabbitMQConf The rabbitmq.config file to use (in erlang format) + * @return This container. + */ + public RabbitMQContainer withRabbitMQConfigErlang(MountableFile rabbitMQConf) { + withEnv("RABBITMQ_CONFIG_FILE", "/etc/rabbitmq/rabbitmq-custom.config"); + return withCopyFileToContainer(rabbitMQConf, "/etc/rabbitmq/rabbitmq-custom.config"); + } +} diff --git a/modules/rabbitmq/src/test/java/org/testcontainers/rabbitmq/RabbitMQContainerTest.java b/modules/rabbitmq/src/test/java/org/testcontainers/rabbitmq/RabbitMQContainerTest.java new file mode 100644 index 00000000000..b3434b2d69d --- /dev/null +++ b/modules/rabbitmq/src/test/java/org/testcontainers/rabbitmq/RabbitMQContainerTest.java @@ -0,0 +1,123 @@ +package org.testcontainers.rabbitmq; + +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.ConnectionFactory; +import com.rabbitmq.client.DeliverCallback; +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.MountableFile; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; + +class RabbitMQContainerTest { + + public static final int DEFAULT_AMQPS_PORT = 5671; + + public static final int DEFAULT_AMQP_PORT = 5672; + + public static final int DEFAULT_HTTPS_PORT = 15671; + + public static final int DEFAULT_HTTP_PORT = 15672; + + @Test + void shouldCreateRabbitMQContainer() { + try (RabbitMQContainer container = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE)) { + container.start(); + + assertThat(container.getAdminPassword()).isEqualTo("guest"); + assertThat(container.getAdminUsername()).isEqualTo("guest"); + + assertThat(container.getAmqpsUrl()) + .isEqualTo(String.format("amqps://%s:%d", container.getHost(), container.getAmqpsPort())); + assertThat(container.getAmqpUrl()) + .isEqualTo(String.format("amqp://%s:%d", container.getHost(), container.getAmqpPort())); + assertThat(container.getHttpsUrl()) + .isEqualTo(String.format("https://%s:%d", container.getHost(), container.getHttpsPort())); + assertThat(container.getHttpUrl()) + .isEqualTo(String.format("http://%s:%d", container.getHost(), container.getHttpPort())); + + assertThat(container.getLivenessCheckPortNumbers()) + .containsExactlyInAnyOrder( + container.getMappedPort(DEFAULT_AMQP_PORT), + container.getMappedPort(DEFAULT_AMQPS_PORT), + container.getMappedPort(DEFAULT_HTTP_PORT), + container.getMappedPort(DEFAULT_HTTPS_PORT) + ); + + assertFunctionality(container); + } + } + + @Test + void shouldCreateRabbitMQContainerWithCustomCredentials() { + try ( + RabbitMQContainer container = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE) + .withAdminUser("admin") + .withAdminPassword("admin") + ) { + container.start(); + + assertThat(container.getAdminPassword()).isEqualTo("admin"); + assertThat(container.getAdminUsername()).isEqualTo("admin"); + + assertFunctionality(container); + } + } + + @Test + void shouldMountConfigurationFile() { + try (RabbitMQContainer container = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE)) { + container.withRabbitMQConfig(MountableFile.forClasspathResource("/rabbitmq-custom.conf")); + container.start(); + + assertThat(container.getLogs()).contains("debug"); // config file changes log level to `debug` + } + } + + @Test + void shouldMountConfigurationFileErlang() { + try (RabbitMQContainer container = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE)) { + container.withRabbitMQConfigErlang(MountableFile.forClasspathResource("/rabbitmq-custom.config")); + container.start(); + + assertThat(container.getLogs()).contains("debug"); // config file changes log level to `debug` + } + } + + @Test + void shouldMountConfigurationFileSysctl() { + try (RabbitMQContainer container = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE)) { + container.withRabbitMQConfigSysctl(MountableFile.forClasspathResource("/rabbitmq-custom.conf")); + container.start(); + + assertThat(container.getLogs()).contains("debug"); // config file changes log level to `debug` + } + } + + private void assertFunctionality(RabbitMQContainer container) { + String queueName = "test-queue"; + String text = "Hello World!"; + + ConnectionFactory factory = new ConnectionFactory(); + factory.setHost(container.getHost()); + factory.setPort(container.getAmqpPort()); + factory.setUsername(container.getAdminUsername()); + factory.setPassword(container.getAdminPassword()); + try (Connection connection = factory.newConnection(); Channel channel = connection.createChannel()) { + channel.queueDeclare(queueName, false, false, false, null); + channel.basicPublish("", queueName, null, text.getBytes()); + + DeliverCallback deliverCallback = (consumerTag, delivery) -> { + String message = new String(delivery.getBody(), StandardCharsets.UTF_8); + assertThat(message).isEqualTo(text); + }; + channel.basicConsume(queueName, true, deliverCallback, consumerTag -> {}); + } catch (IOException | TimeoutException e) { + throw new RuntimeException(e); + } + } +} diff --git a/modules/rabbitmq/src/test/java/org/testcontainers/rabbitmq/RabbitMQTestImages.java b/modules/rabbitmq/src/test/java/org/testcontainers/rabbitmq/RabbitMQTestImages.java new file mode 100644 index 00000000000..56294f1c196 --- /dev/null +++ b/modules/rabbitmq/src/test/java/org/testcontainers/rabbitmq/RabbitMQTestImages.java @@ -0,0 +1,7 @@ +package org.testcontainers.rabbitmq; + +import org.testcontainers.utility.DockerImageName; + +public interface RabbitMQTestImages { + DockerImageName RABBITMQ_IMAGE = DockerImageName.parse("rabbitmq:3.7.25-management-alpine"); +} diff --git a/modules/rabbitmq/src/test/resources/certs/ca_certificate.pem b/modules/rabbitmq/src/test/resources/certs/ca_certificate.pem new file mode 100644 index 00000000000..f22df829095 --- /dev/null +++ b/modules/rabbitmq/src/test/resources/certs/ca_certificate.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDRzCCAi+gAwIBAgIJAJJIMzvZuRzlMA0GCSqGSIb3DQEBCwUAMDExIDAeBgNV +BAMMF1RMU0dlblNlbGZTaWduZWR0Um9vdENBMQ0wCwYDVQQHDAQkJCQkMCAXDTE5 +MDUwMjA3MjI0OVoYDzIxMTkwNDA4MDcyMjQ5WjAxMSAwHgYDVQQDDBdUTFNHZW5T +ZWxmU2lnbmVkdFJvb3RDQTENMAsGA1UEBwwEJCQkJDCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBAKko8FmfzrLHyZckvdR1oiSZf80m0t66TMqtLat1Oxjh +CjsxvswwJ/m2I5dM48hwZ+0b2ufkvaudLPq/8jDGyONVfjMGlbe1YlmQMDC7YWdI +XM1nCWAZIKaOHwIkfswuVBAdBVYV4Polu6wjVt5edEpl/IWEpPicXjLOY1Fw3q67 +5tP2Mmo6TJg5YqgB4fH4SmajtP3j+H4puQ8ZPIs26mInEgfCyrMWey/oQX8qqMph +pKMEJYE7DHawriFraOooJadJYojbY5H27nmJe8yXURb3wSQSaKnFZL25cmVm2kue +/lw+n+a2wLdHdU4cmghCURalhcXUNZe7UbdRZ9e9r2cCAwEAAaNgMF4wCwYDVR0P +BAQDAgEGMB0GA1UdDgQWBBSZiNur/XHsqSfdWnB1NPi/ql5+tzAfBgNVHSMEGDAW +gBSZiNur/XHsqSfdWnB1NPi/ql5+tzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4IBAQAar/db/T7izD4pyh2titl7Dkgp2iTditfgqRlU0yVGiiB6rLmY +sYE2QAuFhgqyRLPcjVV8F39iRJHQ17SGT8e2iAaUTnbQj0AiskKjonF9+quKuVbr +TpYHk+guS0Jn2rU6HK8WQeYZOh3WdLTu4ArXkxywgwVssQQ9JmpTd9YEYePWfs7i +WZB6AQyL9CD3z1j4i1G4ft6pB1Ps5XjznqMZ2//7AUpoRTrettWqorPWwudQ9yna +B4S6KtvpnxUQSeHJW6Q4NvTrOsvHEOCa6OtwYbWmLf+qbpPb8oHt9UF3ze2PJopB +QzsQop1+gPudG0DX0SgyuQT+SsFjYlDazZdZ +-----END CERTIFICATE----- diff --git a/modules/rabbitmq/src/test/resources/certs/ca_key.pem b/modules/rabbitmq/src/test/resources/certs/ca_key.pem new file mode 100644 index 00000000000..29ed148fb01 --- /dev/null +++ b/modules/rabbitmq/src/test/resources/certs/ca_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCpKPBZn86yx8mX +JL3UdaIkmX/NJtLeukzKrS2rdTsY4Qo7Mb7MMCf5tiOXTOPIcGftG9rn5L2rnSz6 +v/IwxsjjVX4zBpW3tWJZkDAwu2FnSFzNZwlgGSCmjh8CJH7MLlQQHQVWFeD6Jbus +I1beXnRKZfyFhKT4nF4yzmNRcN6uu+bT9jJqOkyYOWKoAeHx+Epmo7T94/h+KbkP +GTyLNupiJxIHwsqzFnsv6EF/KqjKYaSjBCWBOwx2sK4ha2jqKCWnSWKI22OR9u55 +iXvMl1EW98EkEmipxWS9uXJlZtpLnv5cPp/mtsC3R3VOHJoIQlEWpYXF1DWXu1G3 +UWfXva9nAgMBAAECggEAVHUVM5o/aDGp6+WzRa2Jy/47ueEFbaDUkGjQoYeBfxV0 +t0NKAMaWXu3abUM9gyjkKpU6wYcKT/HEsFk/gazuRdq8jJtgCv3r4c3E0b/sjNWr +R/6Gxs0k6SOSRc6U5DrJS9ZBgM6hqiNGxVZLm/DK3Q54eu1UNLBVs8Yp/lJ9S/3C +oM6lhex6SoCTiZyFb3ZvVTmBAZe/ROFXZAtmVac1KORBFN8FpkXQcB3EPTkvZmNJ +sSp8dVPBLABm6gAbdJRLVqqndkHDPsopFGHLGzo4LtR7AlEklfxBdC/XlYmudZeY +peWba+ZdLH3sCybE55Xcm7nTfRRSQSLDv++r73GCCQKBgQDe/7ltr8N+gWpb1fDC +nEyBU4WHoTRp09sD+MZe3B06PX3WNRrf+3wHj9oB9UkhZ1baCq84mRzBiUKpAYrw +GrC4iL4kqbWeIZzcJqLZ7zk1zzHzW0+zzNJSjzE8dq3W26+SnuLqKDFpkxtAyIMQ +Rp9AZJIW8q9ay6ZCsZk/NNBoKwKBgQDCMYh53dXKiYtr6tAXXManWWpQ24af6WKl +3faXCw5eXG7ic5bNZD7ql+DWJzRylkI4yeLDvWAsHprtyIBYmkRki3lBAnpLeHY8 +7XWjZeOVeIpiSbG+1Fo7LrOuWi3wYGOZ61QUTjqJhvZJpzZBMEgo7cx+WQhlrioI +9BbXqPabtQKBgAZJv7jQE/slOxKL3dYfAilDaaiBazDwwGRER5O1MT3LLhk0NiXK +uZyc+dDEUeOXPmO3mWlHKABtFmwdlwVeO014zaPLBUwINpwemsj6beqOhSIPmRfA +1s1tLD5AOnasiy7fPBbOO1Z2x3X0MX3r/+GY9GWhQkCVLYMD7wZRPu8xAoGBAJMr +PNW2s+ZJtPq1Orzp17dOAU+D/xPDqLoxbEbt3xbOEE7X8Mp5lWDudzt0/L92dntZ +LNzQ8UiebSWVlQcQ6pIUTXFiMlJt2ZW1FDkf54kIkD+KwATyI+vEKfIRb81DD1i/ +yrmUy7IcMRyCd5CRya4TAa4jRUTh6ANfEMyhxTsdAoGBAIFub7nikxPQ8J4ItWq8 +EKOptyziJ38KnL2nZnFyVDu6uZDtSk9Tf1dHqWy8D4yP9K3OLshipEp7rnNG25dr +e7fkB58K77eSoaEpCZmwzPZstwkXbeBl+DHerDyC2UBXwIUWWFq+FuCdRQqCoYmU +OGiaT92l+UInN7VchZjJS7Oj +-----END PRIVATE KEY----- diff --git a/modules/rabbitmq/src/test/resources/certs/client_certificate.pem b/modules/rabbitmq/src/test/resources/certs/client_certificate.pem new file mode 100644 index 00000000000..f6d75c8c272 --- /dev/null +++ b/modules/rabbitmq/src/test/resources/certs/client_certificate.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKDCCAhCgAwIBAgIBAjANBgkqhkiG9w0BAQsFADAxMSAwHgYDVQQDDBdUTFNH +ZW5TZWxmU2lnbmVkdFJvb3RDQTENMAsGA1UEBwwEJCQkJDAgFw0xOTA1MDIwNzIy +NDlaGA8yMTE5MDQwODA3MjI0OVowIzEQMA4GA1UEAwwHQzY1U1RUMjEPMA0GA1UE +CgwGY2xpZW50MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtcAse1RO +mRngQ4NNFFgyPgbq2eZc3c/ERKdg2xKi2XESMIbOreVkoBRq1IXoxkJHgQZJvPP8 +JbH7hmY+DFZcreszlEk5FI7bvxFNQM3CKsy2LeEcN9BTchSEsWgmSbwiZJ31kEB8 +u/i+/GpgTTxtwPWi/zTep+7fKM4dyzYX9WcMu8wif3jKIzqXVUqHpWopFUK9zLQf +JRnyfiD2uYbfcZYopsIG+wdL2Ebgfr8fpc+YsWn0aA0zBiEFAKYh9bewPqDIZKyg +qd4FKzeUaqGHynttNxKDJ9z0oD84PKFEOuryf7V8ODU8+kI1+y/+3C5Hlq6n92y6 +eSPn81W44+WZeQIDAQABo1cwVTAJBgNVHRMEAjAAMAsGA1UdDwQEAwIFoDATBgNV +HSUEDDAKBggrBgEFBQcDAjAmBgNVHREEHzAdggdDNjVTVFQyggdDNjVTVFQyggls +b2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggEBAH3oqsmr+hIfMEpZUolPNuAl43TF +KUu14SeFyS+P8fEIQVGkHdpEpHzC0G+fvREAy4YSw0WE8upOq6tkbbjen222SP0J +C2vj0B9cCPLZUQ4kW6Z7JJD/1qryML0XB9uMPHqDeV/yBETJZW80TV0f7p8GWKXe ++spu1W13rJ0jvQQd9anNEx3VhcKTwBIcTAhTHothxFLLhX1zb/oVZcl1riBopBFm +WtrZRTCHs31o5GBnIs540uH0f2iCk7wUf8M/evxv5F1hms+NV7kfOz7fyCEXc9La +P9pDjuxTNOUeOZH815BR9Lj71txY9AdjXqEXj7JdecACLsjHc+LMEP36cls= +-----END CERTIFICATE----- diff --git a/modules/rabbitmq/src/test/resources/certs/client_key.p12 b/modules/rabbitmq/src/test/resources/certs/client_key.p12 new file mode 100644 index 00000000000..15ce61136e2 Binary files /dev/null and b/modules/rabbitmq/src/test/resources/certs/client_key.p12 differ diff --git a/modules/rabbitmq/src/test/resources/certs/client_key.pem b/modules/rabbitmq/src/test/resources/certs/client_key.pem new file mode 100644 index 00000000000..f05f935588d --- /dev/null +++ b/modules/rabbitmq/src/test/resources/certs/client_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAtcAse1ROmRngQ4NNFFgyPgbq2eZc3c/ERKdg2xKi2XESMIbO +reVkoBRq1IXoxkJHgQZJvPP8JbH7hmY+DFZcreszlEk5FI7bvxFNQM3CKsy2LeEc +N9BTchSEsWgmSbwiZJ31kEB8u/i+/GpgTTxtwPWi/zTep+7fKM4dyzYX9WcMu8wi +f3jKIzqXVUqHpWopFUK9zLQfJRnyfiD2uYbfcZYopsIG+wdL2Ebgfr8fpc+YsWn0 +aA0zBiEFAKYh9bewPqDIZKygqd4FKzeUaqGHynttNxKDJ9z0oD84PKFEOuryf7V8 +ODU8+kI1+y/+3C5Hlq6n92y6eSPn81W44+WZeQIDAQABAoIBAQCooWMkEnbSakW/ +niV4CNSk5DombiwfyVOq9zlQSZw670QXLhy5D6srM4ZjJNNyj7BUMAdef2mld9uN +OXO8cqyO2TkEDmQdhOayAlWRGNdcao9lRgWua2Xg5NSw3ZcYtquae0yJyKtypDpf +bDtprfWPINlYvC8R1PnMnGDcWJYmIyQyQuB2OJVCJ4ayhS4miBk91fR0T0yMhmlZ +RKZWbECvQirwA1podKvVSpwhAEUlLkVWRqVVYkiadWoXc8u6Dg2NrI9KNCgk2/3W +m8sVC9VgjkXw5L8cToTBmTrjRS831O0JR1iDuZIzbwliJhGtVcHtiTUvybu3boYL +R4F9Z4fhAoGBAN0h+7yBIr7TvCXyURfemWrcWBYWmMwlr6GdcE0WACnUUiFL7aQs +0Mo5DWnwUG74hk1E8TG6oRU9oJs+69295cXe1m6wUwZySh4qlqfFEwnB2PzZwGrS +FnRrCnJGiyLfY5v5hFlzWl/APtuxXGMa3S+ehbtFBhvLfN6vmSPpBTzNAoGBANJo +iM6AK+ldkbi9SCES8zMWPLNWrEh7SiA+bItyoKOY275TUUxVLz0MP2S1B2yGo9Og +fe6fWGeXfMgcL7+1q2cwMuieIaG5Qcs4u+3qJNVmerf5PlMJUkN7D3Prw1C73SLq +cQVaRXs4LBpmMC5ajOODrZt/GdrRrn2D13kQKI9dAoGAaqTW+MP2c709Qbeo8DAE +IQr+2DgxnFKYbwK0hBiWH5Yrva8WflS2pK/7Dho9UCc+7cjP4UG2Kb481GH18kyA +oXqkQ2F5yOQZZo73dRWP5ua7tMV3DI0hEygEM7RdqYW+Thx5fYIqFX9rURwqCAmO +nkZ/DB9voLv0Dpj06+KXCgkCgYAmigck680fPYhHckQX6sSpAtWzc5iy3gJBza1M +DX7m+ESno9MsTB4O7INgCtiFRFQVmzv1zTIAJ3svnBoS30+54tYwTWaTnL80Xfvu +JAkDHXY05G5J/1cWDSBTd0ebLg3fK1nwRQyc+Tj6zOTeWK+drKzL4of10JpJWzDI +d/E18QKBgQC9IIZ5oc30NL5Ie5ypcHG4xrim6OR0akeDDeZfV6BrhOnpdi8l4aIC +Jswilj/PQF657s5trujxN3YxUeCunCtuOqcrE0O5AAL4CTQGy3LIq4IGYWGV/19I +ZTyM0Bf5iPEpVYLTflf9wW2UElPw4Uz2+ky0mKBhtRBYMAYAR0NvfA== +-----END RSA PRIVATE KEY----- diff --git a/modules/rabbitmq/src/test/resources/certs/server_certificate.pem b/modules/rabbitmq/src/test/resources/certs/server_certificate.pem new file mode 100644 index 00000000000..8a783183380 --- /dev/null +++ b/modules/rabbitmq/src/test/resources/certs/server_certificate.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDajCCAlKgAwIBAgIBATANBgkqhkiG9w0BAQsFADAxMSAwHgYDVQQDDBdUTFNH +ZW5TZWxmU2lnbmVkdFJvb3RDQTENMAsGA1UEBwwEJCQkJDAgFw0xOTA1MDIwNzIy +NDlaGA8yMTE5MDQwODA3MjI0OVowIzEQMA4GA1UEAwwHQzY1U1RUMjEPMA0GA1UE +CgwGc2VydmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqR0QXKtb +KVeEuCmZGcZAlAlTBC8E/G3UuX6qKwTR1xEOvUWeBH1n0WeXXGd/p/y6P4lRBeWN +BZ9KcvIlNDeDMy05NfxnO1vnJk9E8/0xwMiY1LJdMHzIzhmrrqXo0u3DT8MmoNR6 +7CTcnG21gi1GrjW8a747yFF0xfukEc6FkyVqLsjtCkHPwrc/sBHVS3aivNWGkJzA +eBXBdWJAg3ZC6T9U+Y8cndWQrpYMJvek1IewlyDSspHZDFmM1OwVwypnMt4fGgaX +5IlUMnNgKmisOSuI529rxLF+mvYIQLRl5bP+1/c9JD5MZ5krA3SrjdwRFS3sQXC3 +nuHqJofFXNkbXQIDAQABo4GYMIGVMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgWgMBMG +A1UdJQQMMAoGCCsGAQUFBwMBMCYGA1UdEQQfMB2CB0M2NVNUVDKCB0M2NVNUVDKC +CWxvY2FsaG9zdDAdBgNVHQ4EFgQURq22sa46tA0SGHhEm9jxGP9aDrswHwYDVR0j +BBgwFoAUmYjbq/1x7Kkn3VpwdTT4v6pefrcwDQYJKoZIhvcNAQELBQADggEBAKUP +7RgmJyMVoHxg46F1fjWVhlF4BbQuEtB8mC+4G4e68lDU/TPAbmB3aj91oQDgBiTd +R2O7U6tyitxxrU2r7rFAHGhFHeyCQ3yZMwydO2V3Nm2Ywzdyk8er4yghjg9FS8tH +egDGDDod3l1yrAbHHuXmzDjnAFwHwRkm5cYUz00/IuZ3sQZ70XofL3KXNj1tAtfK +PSpdSAxSTO99ofjVKjlyywQSZKNbXfqD5DGz8e0rmqPfZ+3zi75E5nEuJ3UI2wXg +LuI4j6FIzNQyei/FdSynktcIm+hefQEyex4cho4C8RYB2S5S8RWrnP9jOzsaQFHn +bHXf7dKwRfA6/u8JmtQ= +-----END CERTIFICATE----- diff --git a/modules/rabbitmq/src/test/resources/certs/server_key.p12 b/modules/rabbitmq/src/test/resources/certs/server_key.p12 new file mode 100644 index 00000000000..8d97d92e2b2 Binary files /dev/null and b/modules/rabbitmq/src/test/resources/certs/server_key.p12 differ diff --git a/modules/rabbitmq/src/test/resources/certs/server_key.pem b/modules/rabbitmq/src/test/resources/certs/server_key.pem new file mode 100644 index 00000000000..dfbfb6db7e3 --- /dev/null +++ b/modules/rabbitmq/src/test/resources/certs/server_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAqR0QXKtbKVeEuCmZGcZAlAlTBC8E/G3UuX6qKwTR1xEOvUWe +BH1n0WeXXGd/p/y6P4lRBeWNBZ9KcvIlNDeDMy05NfxnO1vnJk9E8/0xwMiY1LJd +MHzIzhmrrqXo0u3DT8MmoNR67CTcnG21gi1GrjW8a747yFF0xfukEc6FkyVqLsjt +CkHPwrc/sBHVS3aivNWGkJzAeBXBdWJAg3ZC6T9U+Y8cndWQrpYMJvek1IewlyDS +spHZDFmM1OwVwypnMt4fGgaX5IlUMnNgKmisOSuI529rxLF+mvYIQLRl5bP+1/c9 +JD5MZ5krA3SrjdwRFS3sQXC3nuHqJofFXNkbXQIDAQABAoIBAA0dxvYZCEIFmrKZ +71jzanDQ5FJvvyhA8H3OmC4r+oZ+uTDu5FmezF2OdkvhbyI9VMi2wsT9T9m+yAxw +QXhyUce3WzeXsv4Em8H55fQykBhOtqPQja/EDeMGVK2ACrXJYRufnDBfKoWEOmQb +kjddgZzjaBDHOWXJA5CTet8ysGOAJBTxyzU69k5Vj9B5abG9CofNzGOFF+Uleff5 +ip3sz7JpDXCex3oEs98veco6+8i/MZNo3BnwB5J+P+2MFFKONfPwuNyKAWBza2/X +66Lk3xXBjLJJ+Ww16jkqueTXEq6GCFXavNfdL9aonth5V5YYR/cj+2u2LM1oj9cJ +bp0xbvUCgYEA2Svq1DyR9cfTwrbc/0J2JfrjavClzDYU2oeO2fSU85WEEjJguaja +17Vdo/UsJtiUiSq4UhI1n0haaIpTBCeF2tHGXVEYZ7ZBi1zzdWbWlDxFmi+rcE57 +ytx5w+iLE366tQEMa/Jn3bly54pG5JZAr9TXkpg9sMbzWZri2ocyU/cCgYEAx1l/ +9X9C/OruDp/MhhmVwKfw/X2+RhZRuv0pPcpJu7/gIoLgaxNj41XSeLqLYMlisaRk +GFU17GFXtfRGE1a3z+jj8UPTP2sHk3w8m0yI+pgWgsvG0TJ0B+XsRfpVxFiIoaEs +3AsBaGR+hrRY1dpaJ9Cu3J9mEeToTpbCzPzVDksCgYEAzwSvWNvYY4u2UFHSvz2S +tMfBzCpUUiNno50/ToN5De4ENPhy/eh5nNDVz7qh+PHSPiNMC2gyV4E4NZlOY5Jt +Zdc8ma35brvtJTVZGxwKBsqhqsYwTeFy3kFnjZn6IX5X6r1yIuCzpEfowdEtnS+h +wDtLuAGKJR6x0UP1Zk0ka6cCgYBGE6I1rJzhx7wTi/0bjtbjuKWwlolSnfnxH5ll +zTyKMXMa7qLxQQm2Gq84HWtthJ2bEMzW+O1RwQ5SOiKAHdXT0mx+nXcfLgKlx+CO +PyNP5DLVm8iyNWgwdpTOLKgFs5GkL8JTP9Mo3VrVA4TO+EkFAgjWKXp6A9vd9IVa +Be7nbQKBgAVtFKuf9nbCMfN+W1gN0vlW2lwxCTa4w0KHgIlGIIvnYVuixSgu9fGt +uylQcQirEjqrdzdVF9L2BQ37ZcLaGh1LoCmx8XVCX/HhbwW2RP798P3Z1P7htm16 +ha5OfuPjHvoZklbYJo6EORJZQehS2VP63pjdnmUeMHPFzrPUevI5 +-----END RSA PRIVATE KEY----- diff --git a/modules/rabbitmq/src/test/resources/certs/truststore.jks b/modules/rabbitmq/src/test/resources/certs/truststore.jks new file mode 100644 index 00000000000..84fce84266b Binary files /dev/null and b/modules/rabbitmq/src/test/resources/certs/truststore.jks differ diff --git a/modules/rabbitmq/src/test/resources/logback-test.xml b/modules/rabbitmq/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/rabbitmq/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/rabbitmq/src/test/resources/rabbitmq-custom.conf b/modules/rabbitmq/src/test/resources/rabbitmq-custom.conf new file mode 100644 index 00000000000..a2994c78602 --- /dev/null +++ b/modules/rabbitmq/src/test/resources/rabbitmq-custom.conf @@ -0,0 +1 @@ +log.console.level = debug diff --git a/modules/rabbitmq/src/test/resources/rabbitmq-custom.config b/modules/rabbitmq/src/test/resources/rabbitmq-custom.config new file mode 100644 index 00000000000..353a854fc42 --- /dev/null +++ b/modules/rabbitmq/src/test/resources/rabbitmq-custom.config @@ -0,0 +1,9 @@ +[ +{rabbit, + [ + {log, + [{console, [{level, debug}]}] + } + ] +} +]. diff --git a/modules/redpanda/build.gradle b/modules/redpanda/build.gradle new file mode 100644 index 00000000000..40c88ea347e --- /dev/null +++ b/modules/redpanda/build.gradle @@ -0,0 +1,10 @@ +description = "Testcontainers :: Redpanda" + +dependencies { + api project(':testcontainers') + shaded 'org.freemarker:freemarker:2.3.34' + + testImplementation 'org.apache.kafka:kafka-clients:4.3.1' + testImplementation 'io.rest-assured:rest-assured:5.5.7' + testImplementation 'org.awaitility:awaitility:4.3.0' +} diff --git a/modules/redpanda/src/main/java/org/testcontainers/redpanda/RedpandaContainer.java b/modules/redpanda/src/main/java/org/testcontainers/redpanda/RedpandaContainer.java new file mode 100644 index 00000000000..e8655beec45 --- /dev/null +++ b/modules/redpanda/src/main/java/org/testcontainers/redpanda/RedpandaContainer.java @@ -0,0 +1,377 @@ +package org.testcontainers.redpanda; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import freemarker.template.Configuration; +import freemarker.template.Template; +import lombok.AllArgsConstructor; +import lombok.Cleanup; +import lombok.Data; +import lombok.SneakyThrows; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * Testcontainers implementation for Redpanda. + *

+ * Supported images: {@code redpandadata/redpanda}, {@code docker.redpanda.com/redpandadata/redpanda} + *

+ * Exposed ports: + *

    + *
  • Broker: 9092
  • + *
  • Schema Registry: 8081
  • + *
  • Proxy: 8082
  • + *
+ */ +public class RedpandaContainer extends GenericContainer { + + private static final String REDPANDA_FULL_IMAGE_NAME = "docker.redpanda.com/redpandadata/redpanda"; + + private static final String IMAGE_NAME = "redpandadata/redpanda"; + + private static final DockerImageName REDPANDA_IMAGE = DockerImageName.parse(REDPANDA_FULL_IMAGE_NAME); + + private static final DockerImageName IMAGE = DockerImageName.parse(IMAGE_NAME); + + private static final int REDPANDA_PORT = 9092; + + private static final int REDPANDA_ADMIN_PORT = 9644; + + private static final int SCHEMA_REGISTRY_PORT = 8081; + + private static final int REST_PROXY_PORT = 8082; + + private boolean enableAuthorization; + + private String authenticationMethod = "none"; + + private String schemaRegistryAuthenticationMethod = "none"; + + private final List superusers = new ArrayList<>(); + + @Deprecated + private final Set> listenersValueSupplier = new HashSet<>(); + + private final Map> listeners = new HashMap<>(); + + public RedpandaContainer(String image) { + this(DockerImageName.parse(image)); + } + + public RedpandaContainer(DockerImageName imageName) { + super(imageName); + imageName.assertCompatibleWith(REDPANDA_IMAGE, IMAGE); + + boolean isLessThanBaseVersion = new ComparableVersion(imageName.getVersionPart()).isLessThan("v22.2.1"); + boolean isPublicCompatibleImage = + REDPANDA_FULL_IMAGE_NAME.equals(imageName.getUnversionedPart()) || + IMAGE_NAME.equals(imageName.getUnversionedPart()); + if (isPublicCompatibleImage && isLessThanBaseVersion) { + throw new IllegalArgumentException("Redpanda version must be >= v22.2.1"); + } + + withExposedPorts(REDPANDA_PORT, REDPANDA_ADMIN_PORT, SCHEMA_REGISTRY_PORT, REST_PROXY_PORT); + withCreateContainerCmdModifier(cmd -> { + cmd.withEntrypoint("/entrypoint-tc.sh"); + cmd.withUser("root:root"); + }); + waitingFor(Wait.forLogMessage(".*Successfully started Redpanda!.*", 1)); + withCopyFileToContainer( + MountableFile.forClasspathResource("testcontainers/entrypoint-tc.sh", 0700), + "/entrypoint-tc.sh" + ); + withCommand("redpanda", "start", "--mode=dev-container", "--smp=1", "--memory=1G"); + } + + @Override + protected void configure() { + this.listenersValueSupplier.stream() + .map(Supplier::get) + .map(Listener::getAddress) + .forEach(this::withNetworkAliases); + this.listeners.keySet().stream().map(listener -> listener.split(":")[0]).forEach(this::withNetworkAliases); + } + + @SneakyThrows + @Override + protected void containerIsStarting(InspectContainerResponse containerInfo) { + super.containerIsStarting(containerInfo); + + Configuration cfg = new Configuration(Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS); + cfg.setClassLoaderForTemplateLoading(getClass().getClassLoader(), "testcontainers"); + cfg.setDefaultEncoding("UTF-8"); + + copyFileToContainer(getBootstrapFile(cfg), "/etc/redpanda/.bootstrap.yaml"); + copyFileToContainer(getRedpandaFile(cfg), "/etc/redpanda/redpanda.yaml"); + } + + /** + * Returns the bootstrap servers address. + * @return the bootstrap servers address + */ + public String getBootstrapServers() { + return String.format("PLAINTEXT://%s:%s", getHost(), getMappedPort(REDPANDA_PORT)); + } + + /** + * Returns the schema registry address. + * @return the schema registry address + */ + public String getSchemaRegistryAddress() { + return String.format("http://%s:%s", getHost(), getMappedPort(SCHEMA_REGISTRY_PORT)); + } + + /** + * Returns the admin address. + * @return the admin address + */ + public String getAdminAddress() { + return String.format("http://%s:%s", getHost(), getMappedPort(REDPANDA_ADMIN_PORT)); + } + + /** + * Returns the rest proxy address. + * @return the rest proxy address + */ + public String getRestProxyAddress() { + return String.format("http://%s:%s", getHost(), getMappedPort(REST_PROXY_PORT)); + } + + /** + * Enables authorization. + * @return this {@link RedpandaContainer} instance + */ + public RedpandaContainer enableAuthorization() { + this.enableAuthorization = true; + return this; + } + + /** + * Enables SASL. + * @return this {@link RedpandaContainer} instance + */ + public RedpandaContainer enableSasl() { + this.authenticationMethod = "sasl"; + return this; + } + + /** + * Enables Http Basic Auth for Schema Registry. + * @return this {@link RedpandaContainer} instance + */ + public RedpandaContainer enableSchemaRegistryHttpBasicAuth() { + this.schemaRegistryAuthenticationMethod = "http_basic"; + return this; + } + + /** + * Register username as a superuser. + * @param username username to register as a superuser + * @return this {@link RedpandaContainer} instance + */ + public RedpandaContainer withSuperuser(String username) { + this.superusers.add(username); + return this; + } + + /** + * Add a {@link Supplier} that will provide a listener with format {@code host:port}. + * Host will be added as a network alias. + *

+ * The listener will be added to the default listeners. + *

+ * Default listeners: + *

    + *
  • 0.0.0.0:9092
  • + *
  • 0.0.0.0:9093
  • + *
+ *

+ * Default advertised listeners: + *

    + *
  • {@code container.getHost():container.getMappedPort(9092)}
  • + *
  • 127.0.0.1:9093
  • + *
+ * @param listenerSupplier a supplier that will provide a listener + * @return this {@link RedpandaContainer} instance + * @deprecated use {@link #withListener(String, Supplier)} instead + */ + @Deprecated + public RedpandaContainer withListener(Supplier listenerSupplier) { + String[] parts = listenerSupplier.get().split(":"); + this.listenersValueSupplier.add(() -> new Listener(parts[0], Integer.parseInt(parts[1]))); + return this; + } + + /** + * Add a listener in the format {@code host:port}. + * Host will be included as a network alias. + *

+ * Use it to register additional connections to the Kafka broker within the same container network. + *

+ * The listener will be added to the list of default listeners. + *

+ * Default listeners: + *

    + *
  • 0.0.0.0:9092
  • + *
  • 0.0.0.0:9093
  • + *
+ *

+ * The listener will be added to the list of default advertised listeners. + *

+ * Default advertised listeners: + *

    + *
  • {@code container.getConfig().getHostName():9092}
  • + *
  • {@code container.getHost():container.getMappedPort(9093)}
  • + *
+ * @param listener a listener with format {@code host:port} + * @return this {@link RedpandaContainer} instance + */ + public RedpandaContainer withListener(String listener) { + this.listeners.put(listener, () -> listener); + return this; + } + + /** + * Add a listener in the format {@code host:port} and a {@link Supplier} for the advertised listener. + * Host from listener will be included as a network alias. + *

+ * Use it to register additional connections to the Kafka broker from outside the container network + *

+ * The listener will be added to the list of default listeners. + *

+ * Default listeners: + *

    + *
  • 0.0.0.0:9092
  • + *
  • 0.0.0.0:9093
  • + *
+ *

+ * The {@link Supplier} will be added to the list of default advertised listeners. + *

+ * Default advertised listeners: + *

    + *
  • {@code container.getConfig().getHostName():9092}
  • + *
  • {@code container.getHost():container.getMappedPort(9093)}
  • + *
+ * @param listener a supplier that will provide a listener + * @param advertisedListener a supplier that will provide a listener + * @return this {@link RedpandaContainer} instance + */ + public RedpandaContainer withListener(String listener, Supplier advertisedListener) { + this.listeners.put(listener, advertisedListener); + return this; + } + + private Transferable getBootstrapFile(Configuration cfg) { + Map kafkaApi = new HashMap<>(); + kafkaApi.put("enableAuthorization", this.enableAuthorization); + kafkaApi.put("superusers", this.superusers); + + Map root = new HashMap<>(); + root.put("kafkaApi", kafkaApi); + + String file = resolveTemplate(cfg, "bootstrap.yaml.ftl", root); + + return Transferable.of(file, 0700); + } + + private Transferable getRedpandaFile(Configuration cfg) { + Map kafkaApi = new HashMap<>(); + kafkaApi.put("authenticationMethod", this.authenticationMethod); + kafkaApi.put("enableAuthorization", this.enableAuthorization); + kafkaApi.put("advertisedHost", getHost()); + kafkaApi.put("advertisedPort", getMappedPort(9092)); + + List> listeners = + this.listenersValueSupplier.stream() + .map(Supplier::get) + .map(listener -> { + Map listenerMap = new HashMap<>(); + listenerMap.put("address", listener.getAddress()); + listenerMap.put("port", listener.getPort()); + listenerMap.put("authentication_method", this.authenticationMethod); + return listenerMap; + }) + .collect(Collectors.toList()); + kafkaApi.put("listeners", listeners); + + List> kafkaListeners = + this.listeners.keySet() + .stream() + .map(listener -> { + Map listenerMap = new HashMap<>(); + listenerMap.put("name", listener.split(":")[0]); + listenerMap.put("address", listener.split(":")[0]); + listenerMap.put("port", listener.split(":")[1]); + listenerMap.put("authentication_method", this.authenticationMethod); + return listenerMap; + }) + .collect(Collectors.toList()); + + List> kafkaAdvertisedListeners = + this.listeners.entrySet() + .stream() + .map(entry -> { + String advertisedListener = entry.getValue().get(); + Map listenerMap = new HashMap<>(); + listenerMap.put("name", entry.getKey().split(":")[0]); + listenerMap.put("address", advertisedListener.split(":")[0]); + listenerMap.put("port", advertisedListener.split(":")[1]); + return listenerMap; + }) + .collect(Collectors.toList()); + + Map kafka = new HashMap<>(); + kafka.put("listeners", kafkaListeners); + kafka.put("advertisedListeners", kafkaAdvertisedListeners); + + Map schemaRegistry = new HashMap<>(); + schemaRegistry.put("authenticationMethod", this.schemaRegistryAuthenticationMethod); + + Map root = new HashMap<>(); + root.put("kafkaApi", kafkaApi); + root.put("kafka", kafka); + root.put("schemaRegistry", schemaRegistry); + + String file = resolveTemplate(cfg, "redpanda.yaml.ftl", root); + + return Transferable.of(file, 0700); + } + + @SneakyThrows + private String resolveTemplate(Configuration cfg, String template, Map data) { + Template temp = cfg.getTemplate(template); + + @Cleanup + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + @Cleanup + Writer out = new OutputStreamWriter(byteArrayOutputStream, StandardCharsets.UTF_8); + temp.process(data, out); + + return new String(byteArrayOutputStream.toByteArray(), StandardCharsets.UTF_8); + } + + @Data + @AllArgsConstructor + private static class Listener { + + private String address; + + private int port; + } +} diff --git a/modules/redpanda/src/main/resources/testcontainers/bootstrap.yaml.ftl b/modules/redpanda/src/main/resources/testcontainers/bootstrap.yaml.ftl new file mode 100644 index 00000000000..616415c359e --- /dev/null +++ b/modules/redpanda/src/main/resources/testcontainers/bootstrap.yaml.ftl @@ -0,0 +1,18 @@ +# Injected by testcontainers +# This file contains cluster properties which will only be considered when +# starting the cluster for the first time. Afterwards, you can configure cluster +# properties via the Redpanda Admin API. +superusers: +<#if kafkaApi.superusers?has_content > + <#list kafkaApi.superusers as superuser> + - ${superuser} + +<#else> + [] + + +<#if kafkaApi.enableAuthorization > +kafka_enable_authorization: true + + +auto_create_topics_enabled: true diff --git a/modules/redpanda/src/main/resources/testcontainers/entrypoint-tc.sh b/modules/redpanda/src/main/resources/testcontainers/entrypoint-tc.sh new file mode 100644 index 00000000000..36932d7a27c --- /dev/null +++ b/modules/redpanda/src/main/resources/testcontainers/entrypoint-tc.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +# Wait for testcontainer's injected redpanda config with the port only known after docker start +until grep -q "# Injected by testcontainers" "/etc/redpanda/redpanda.yaml" +do + sleep 0.1 +done +exec /entrypoint.sh $@ \ No newline at end of file diff --git a/modules/redpanda/src/main/resources/testcontainers/redpanda.yaml.ftl b/modules/redpanda/src/main/resources/testcontainers/redpanda.yaml.ftl new file mode 100644 index 00000000000..80fb3c09418 --- /dev/null +++ b/modules/redpanda/src/main/resources/testcontainers/redpanda.yaml.ftl @@ -0,0 +1,85 @@ +# Injected by testcontainers +<#setting boolean_format="c"> +<#setting number_format="c"> +redpanda: + admin: + address: 0.0.0.0 + port: 9644 + + kafka_api: + - address: 0.0.0.0 + name: external + port: 9092 + authentication_method: ${ kafkaApi.authenticationMethod } + + # This listener is required for the schema registry client. The schema + # registry client connects via an advertised listener like a normal Kafka + # client would do. It can't use the other listener because the mapped + # port is not accessible from within the Redpanda container. + - address: 0.0.0.0 + name: internal + port: 9093 + authentication_method: <#if kafkaApi.enableAuthorization >sasl<#else>none + +<#list kafkaApi.listeners as listener> + - address: 0.0.0.0 + name: ${listener.address} + port: ${listener.port} + authentication_method: ${listener.authentication_method} + +<#list kafka.listeners as listener> + - address: ${listener.address} + name: ${listener.name} + port: ${listener.port} + authentication_method: ${listener.authentication_method} + + + advertised_kafka_api: + - address: ${ kafkaApi.advertisedHost } + name: external + port: ${ kafkaApi.advertisedPort } + - address: 127.0.0.1 + name: internal + port: 9093 +<#list kafkaApi.listeners as listener> + - address: ${listener.address} + name: ${listener.address} + port: ${listener.port} + +<#list kafka.advertisedListeners as listener> + - address: ${listener.address} + name: ${listener.name} + port: ${listener.port} + + +schema_registry: + schema_registry_api: + - address: "0.0.0.0" + name: main + port: 8081 + authentication_method: ${ schemaRegistry.authenticationMethod } + +schema_registry_client: + brokers: + - address: localhost + port: 9093 + +pandaproxy: + pandaproxy_api: + - address: 0.0.0.0 + port: 8082 + name: proxy-internal + advertised_pandaproxy_api: + - address: 127.0.0.1 + port: 8082 + name: proxy-internal + +pandaproxy_client: + brokers: + - address: localhost + port: 9093 + +rpk: + kafka_api: + brokers: + - localhost:9093 diff --git a/modules/redpanda/src/test/java/org/testcontainers/redpanda/AbstractRedpanda.java b/modules/redpanda/src/test/java/org/testcontainers/redpanda/AbstractRedpanda.java new file mode 100644 index 00000000000..ce985a59fdb --- /dev/null +++ b/modules/redpanda/src/test/java/org/testcontainers/redpanda/AbstractRedpanda.java @@ -0,0 +1,85 @@ +package org.testcontainers.redpanda; + +import com.google.common.collect.ImmutableMap; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.awaitility.Awaitility; + +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +public class AbstractRedpanda { + + protected void testKafkaFunctionality(String bootstrapServers) throws Exception { + testKafkaFunctionality(bootstrapServers, 1, 1); + } + + protected void testKafkaFunctionality(String bootstrapServers, int partitions, int rf) throws Exception { + try ( + AdminClient adminClient = AdminClient.create( + ImmutableMap.of(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers) + ); + KafkaProducer producer = new KafkaProducer<>( + ImmutableMap.of( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, + bootstrapServers, + ProducerConfig.CLIENT_ID_CONFIG, + UUID.randomUUID().toString() + ), + new StringSerializer(), + new StringSerializer() + ); + KafkaConsumer consumer = new KafkaConsumer<>( + ImmutableMap.of( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, + bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG, + "tc-" + UUID.randomUUID(), + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, + "earliest" + ), + new StringDeserializer(), + new StringDeserializer() + ); + ) { + String topicName = "messages-" + UUID.randomUUID(); + + Collection topics = Collections.singletonList(new NewTopic(topicName, partitions, (short) rf)); + adminClient.createTopics(topics).all().get(30, TimeUnit.SECONDS); + + consumer.subscribe(Collections.singletonList(topicName)); + + producer.send(new ProducerRecord<>(topicName, "testcontainers", "rulezzz")).get(); + + Awaitility + .await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + ConsumerRecords records = consumer.poll(Duration.ofMillis(100)); + + assertThat(records) + .hasSize(1) + .extracting(ConsumerRecord::topic, ConsumerRecord::key, ConsumerRecord::value) + .containsExactly(tuple(topicName, "testcontainers", "rulezzz")); + }); + + consumer.unsubscribe(); + } + } +} diff --git a/modules/redpanda/src/test/java/org/testcontainers/redpanda/CompatibleImageTest.java b/modules/redpanda/src/test/java/org/testcontainers/redpanda/CompatibleImageTest.java new file mode 100644 index 00000000000..1167642615f --- /dev/null +++ b/modules/redpanda/src/test/java/org/testcontainers/redpanda/CompatibleImageTest.java @@ -0,0 +1,20 @@ +package org.testcontainers.redpanda; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class CompatibleImageTest extends AbstractRedpanda { + + public static String[] image() { + return new String[] { "docker.redpanda.com/redpandadata/redpanda:v22.2.1", "redpandadata/redpanda:v22.2.1" }; + } + + @ParameterizedTest + @MethodSource("image") + void shouldProduceAndConsumeMessage(String image) throws Exception { + try (RedpandaContainer container = new RedpandaContainer(image)) { + container.start(); + testKafkaFunctionality(container.getBootstrapServers()); + } + } +} diff --git a/modules/redpanda/src/test/java/org/testcontainers/redpanda/RedpandaContainerTest.java b/modules/redpanda/src/test/java/org/testcontainers/redpanda/RedpandaContainerTest.java new file mode 100644 index 00000000000..6d1f35da0f2 --- /dev/null +++ b/modules/redpanda/src/test/java/org/testcontainers/redpanda/RedpandaContainerTest.java @@ -0,0 +1,438 @@ +package org.testcontainers.redpanda; + +import com.google.common.collect.ImmutableMap; +import io.restassured.RestAssured; +import io.restassured.common.mapper.TypeRef; +import io.restassured.response.Response; +import lombok.SneakyThrows; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.common.config.SaslConfigs; +import org.apache.kafka.common.errors.SaslAuthenticationException; +import org.apache.kafka.common.errors.TopicAuthorizationException; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.SocatContainer; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RedpandaContainerTest extends AbstractRedpanda { + + private static final String REDPANDA_IMAGE = "docker.redpanda.com/redpandadata/redpanda:v22.2.1"; + + private static final DockerImageName REDPANDA_DOCKER_IMAGE = DockerImageName.parse(REDPANDA_IMAGE); + + @Test + void testUsage() throws Exception { + try (RedpandaContainer container = new RedpandaContainer(REDPANDA_DOCKER_IMAGE)) { + container.start(); + testKafkaFunctionality(container.getBootstrapServers()); + } + } + + @Test + void testUsageWithStringImage() throws Exception { + try ( + // constructorWithVersion { + RedpandaContainer container = new RedpandaContainer("docker.redpanda.com/redpandadata/redpanda:v23.1.2") + // } + ) { + container.start(); + testKafkaFunctionality( + // getBootstrapServers { + container.getBootstrapServers() + // } + ); + } + } + + @Test + void testNotCompatibleVersion() { + assertThatThrownBy(() -> new RedpandaContainer("docker.redpanda.com/redpandadata/redpanda:v21.11.19")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Redpanda version must be >= v22.2.1"); + } + + @Test + void redpandadataRedpandaImageVersion2221ShouldNotBeCompatible() { + assertThatThrownBy(() -> new RedpandaContainer("redpandadata/redpanda:v21.11.19")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Redpanda version must be >= v22.2.1"); + } + + @Test + void testSchemaRegistry() { + try (RedpandaContainer container = new RedpandaContainer(REDPANDA_DOCKER_IMAGE)) { + container.start(); + + String subjectsEndpoint = String.format( + "%s/subjects", + // getSchemaRegistryAddress { + container.getSchemaRegistryAddress() + // } + ); + + String subjectName = String.format("test-%s-value", UUID.randomUUID()); + + Response createSubject = RestAssured + .given() + .contentType("application/vnd.schemaregistry.v1+json") + .pathParam("subject", subjectName) + .body("{\"schema\": \"{\\\"type\\\": \\\"string\\\"}\"}") + .when() + .post(subjectsEndpoint + "/{subject}/versions") + .thenReturn(); + assertThat(createSubject.getStatusCode()).isEqualTo(200); + + Response allSubjects = RestAssured.given().when().get(subjectsEndpoint).thenReturn(); + assertThat(allSubjects.getStatusCode()).isEqualTo(200); + assertThat(allSubjects.jsonPath().getList("$")).contains(subjectName); + } + } + + @Test + void testUsageWithListener() throws Exception { + try ( + Network network = Network.newNetwork(); + RedpandaContainer redpanda = new RedpandaContainer("docker.redpanda.com/redpandadata/redpanda:v23.1.7") + .withListener(() -> "redpanda:19092") + .withNetwork(network); + GenericContainer kcat = new GenericContainer<>("confluentinc/cp-kcat:7.9.0") + .withCreateContainerCmdModifier(cmd -> { + cmd.withEntrypoint("sh"); + }) + .withCopyToContainer(Transferable.of("Message produced by kcat"), "/data/msgs.txt") + .withNetwork(network) + .withCommand("-c", "tail -f /dev/null") + ) { + redpanda.start(); + kcat.start(); + + kcat.execInContainer("kcat", "-b", "redpanda:19092", "-t", "msgs", "-P", "-l", "/data/msgs.txt"); + String stdout = kcat + .execInContainer("kcat", "-b", "redpanda:19092", "-C", "-t", "msgs", "-c", "1") + .getStdout(); + + assertThat(stdout).contains("Message produced by kcat"); + } + } + + @Test + void testUsageWithListenerInTheSameNetwork() throws Exception { + try ( + Network network = Network.newNetwork(); + // registerListener { + RedpandaContainer kafka = new RedpandaContainer("docker.redpanda.com/redpandadata/redpanda:v23.1.7") + .withListener("kafka:19092") + .withNetwork(network); + // } + // createKCatContainer { + GenericContainer kcat = new GenericContainer<>("confluentinc/cp-kcat:7.9.0") + .withCreateContainerCmdModifier(cmd -> { + cmd.withEntrypoint("sh"); + }) + .withCopyToContainer(Transferable.of("Message produced by kcat"), "/data/msgs.txt") + .withNetwork(network) + .withCommand("-c", "tail -f /dev/null") + // } + ) { + kafka.start(); + kcat.start(); + + // produceConsumeMessage { + kcat.execInContainer("kcat", "-b", "kafka:19092", "-t", "msgs", "-P", "-l", "/data/msgs.txt"); + String stdout = kcat + .execInContainer("kcat", "-b", "kafka:19092", "-C", "-t", "msgs", "-c", "1") + .getStdout(); + // } + + assertThat(stdout).contains("Message produced by kcat"); + } + } + + @Test + void testUsageWithListenerFromProxy() throws Exception { + try ( + Network network = Network.newNetwork(); + // createProxy { + SocatContainer socat = new SocatContainer().withNetwork(network).withTarget(2000, "kafka", 19092); + // } + // registerListenerAndAdvertisedListener { + RedpandaContainer kafka = new RedpandaContainer("docker.redpanda.com/redpandadata/redpanda:v23.1.7") + .withListener("kafka:19092", () -> socat.getHost() + ":" + socat.getMappedPort(2000)) + .withNetwork(network) + // } + ) { + socat.start(); + kafka.start(); + // produceConsumeMessageFromProxy { + String bootstrapServers = String.format("%s:%s", socat.getHost(), socat.getMappedPort(2000)); + testKafkaFunctionality(bootstrapServers); + // } + } + } + + @Test + void testUsageWithListenerAndSasl() throws Exception { + final String username = "panda"; + final String password = "pandapass"; + final String algorithm = "SCRAM-SHA-256"; + + try ( + Network network = Network.newNetwork(); + RedpandaContainer redpanda = new RedpandaContainer("docker.redpanda.com/redpandadata/redpanda:v23.1.7") + .enableAuthorization() + .enableSasl() + .withSuperuser("panda") + .withListener("my-panda:29092") + .withNetwork(network); + GenericContainer kcat = new GenericContainer<>("confluentinc/cp-kcat:7.9.0") + .withCreateContainerCmdModifier(cmd -> { + cmd.withEntrypoint("sh"); + }) + .withCopyToContainer(Transferable.of("Message produced by kcat"), "/data/msgs.txt") + .withNetwork(network) + .withCommand("-c", "tail -f /dev/null") + ) { + redpanda.start(); + + String adminUrl = String.format("%s/v1/security/users", redpanda.getAdminAddress()); + Map params = new HashMap<>(); + params.put("username", username); + params.put("password", password); + params.put("algorithm", algorithm); + + RestAssured.given().contentType("application/json").body(params).post(adminUrl).then().statusCode(200); + + kcat.start(); + + kcat.execInContainer( + "kcat", + "-b", + "my-panda:29092", + "-X", + "security.protocol=SASL_PLAINTEXT", + "-X", + "sasl.mechanisms=" + algorithm, + "-X", + "sasl.username=" + username, + "-X", + "sasl.password=" + password, + "-t", + "msgs", + "-P", + "-l", + "/data/msgs.txt" + ); + + String stdout = kcat + .execInContainer( + "kcat", + "-b", + "my-panda:29092", + "-X", + "security.protocol=SASL_PLAINTEXT", + "-X", + "sasl.mechanisms=" + algorithm, + "-X", + "sasl.username=" + username, + "-X", + "sasl.password=" + password, + "-C", + "-t", + "msgs", + "-c", + "1" + ) + .getStdout(); + + assertThat(stdout).contains("Message produced by kcat"); + } + } + + @SneakyThrows + @Test + void enableSaslWithSuccessfulTopicCreation() { + try ( + // security { + RedpandaContainer redpanda = new RedpandaContainer("docker.redpanda.com/redpandadata/redpanda:v23.1.7") + .enableAuthorization() + .enableSasl() + .withSuperuser("superuser-1") + // } + ) { + redpanda.start(); + + createSuperUser(redpanda); + + AdminClient adminClient = getAdminClient(redpanda); + String topicName = "messages-" + UUID.randomUUID(); + Collection topics = Collections.singletonList(new NewTopic(topicName, 1, (short) 1)); + adminClient.createTopics(topics).all().get(30, TimeUnit.SECONDS); + + assertThat(adminClient.listTopics().names().get()).contains(topicName); + } + } + + @Test + void enableSaslWithUnsuccessfulTopicCreation() { + try ( + RedpandaContainer redpanda = new RedpandaContainer("docker.redpanda.com/redpandadata/redpanda:v23.1.7") + .enableAuthorization() + .enableSasl() + ) { + redpanda.start(); + + createSuperUser(redpanda); + + AdminClient adminClient = getAdminClient(redpanda); + String topicName = "messages-" + UUID.randomUUID(); + Collection topics = Collections.singletonList(new NewTopic(topicName, 1, (short) 1)); + + Awaitility + .await() + .untilAsserted(() -> { + assertThatThrownBy(() -> adminClient.createTopics(topics).all().get(30, TimeUnit.SECONDS)) + .hasCauseInstanceOf(TopicAuthorizationException.class); + }); + } + } + + @Test + void enableSaslAndWithAuthenticationError() { + try ( + RedpandaContainer redpanda = new RedpandaContainer("docker.redpanda.com/redpandadata/redpanda:v23.1.7") + .enableAuthorization() + .enableSasl() + ) { + redpanda.start(); + + AdminClient adminClient = getAdminClient(redpanda); + String topicName = "messages-" + UUID.randomUUID(); + Collection topics = Collections.singletonList(new NewTopic(topicName, 1, (short) 1)); + + Awaitility + .await() + .untilAsserted(() -> { + assertThatThrownBy(() -> adminClient.createTopics(topics).all().get(30, TimeUnit.SECONDS)) + .hasCauseInstanceOf(SaslAuthenticationException.class); + }); + } + } + + @Test + void schemaRegistryWithHttpBasic() { + try ( + RedpandaContainer redpanda = new RedpandaContainer("docker.redpanda.com/redpandadata/redpanda:v23.1.7") + .enableSchemaRegistryHttpBasicAuth() + .withSuperuser("superuser-1") + ) { + redpanda.start(); + + createSuperUser(redpanda); + + String subjectsEndpoint = String.format("%s/subjects", redpanda.getSchemaRegistryAddress()); + + RestAssured.when().get(subjectsEndpoint).then().statusCode(401); + + RestAssured + .given() + .auth() + .preemptive() + .basic("superuser-1", "test") + .get(subjectsEndpoint) + .then() + .statusCode(200); + } + } + + @SneakyThrows + @Test + void testRestProxy() { + try (RedpandaContainer redpanda = new RedpandaContainer("docker.redpanda.com/redpandadata/redpanda:v23.1.7")) { + redpanda.start(); + + redpanda.execInContainer("rpk", "topic", "create", "test_topic", "-p", "3"); + + String applicationKafkaJson = "application/vnd.kafka.json.v2+json"; + + String restProxy = redpanda.getRestProxyAddress(); + RestAssured + .given() + .contentType(applicationKafkaJson) + .body( + "{\"records\":[{\"value\":\"jsmith\",\"partition\":0},{\"value\":\"htanaka\",\"partition\":1},{\"value\":\"awalther\",\"partition\":2}]}" + ) + .post(String.format("%s/topics/test_topic", restProxy)) + .then() + .statusCode(200); + + RestAssured + .given() + .contentType("application/vnd.kafka.v2+json") + .body("{\"name\": \"test_consumer\", \"format\": \"json\", \"auto.offset.reset\": \"earliest\"}") + .post(String.format("%s/consumers/test_group", restProxy)) + .then() + .statusCode(200); + RestAssured + .given() + .contentType("application/vnd.kafka.v2+json") + .body("{\"topics\":[\"test_topic\"]}") + .post(String.format("%s/consumers/test_group/instances/test_consumer/subscription", restProxy)) + .then() + .statusCode(204); + + List> response = RestAssured + .given() + .accept(applicationKafkaJson) + .get(String.format("%s/consumers/test_group/instances/test_consumer/records", restProxy)) + .getBody() + .as(new TypeRef>>() {}); + assertThat(response).hasSize(3).extracting("value").containsExactly("jsmith", "htanaka", "awalther"); + } + } + + private AdminClient getAdminClient(RedpandaContainer redpanda) { + String bootstrapServer = String.format("%s:%s", redpanda.getHost(), redpanda.getMappedPort(9092)); + // createAdminClient { + AdminClient adminClient = AdminClient.create( + ImmutableMap.of( + AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, + bootstrapServer, + AdminClientConfig.SECURITY_PROTOCOL_CONFIG, + "SASL_PLAINTEXT", + SaslConfigs.SASL_MECHANISM, + "SCRAM-SHA-256", + SaslConfigs.SASL_JAAS_CONFIG, + "org.apache.kafka.common.security.scram.ScramLoginModule required username=\"superuser-1\" password=\"test\";" + ) + ); + // } + return adminClient; + } + + private void createSuperUser(RedpandaContainer redpanda) { + String adminUrl = String.format("%s/v1/security/users", redpanda.getAdminAddress()); + RestAssured + .given() + .contentType("application/json") + .body("{\"username\": \"superuser-1\", \"password\": \"test\", \"algorithm\": \"SCRAM-SHA-256\"}") + .post(adminUrl) + .then() + .statusCode(200); + } +} diff --git a/modules/redpanda/src/test/resources/logback-test.xml b/modules/redpanda/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/redpanda/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/scylladb/build.gradle b/modules/scylladb/build.gradle new file mode 100644 index 00000000000..480d1958609 --- /dev/null +++ b/modules/scylladb/build.gradle @@ -0,0 +1,8 @@ +description = "Testcontainers :: ScyllaDB" + +dependencies { + api project(":testcontainers") + + testImplementation 'com.scylladb:java-driver-core:4.19.0.8' + testImplementation 'software.amazon.awssdk:dynamodb:2.46.20' +} diff --git a/modules/scylladb/src/main/java/org/testcontainers/scylladb/ScyllaDBContainer.java b/modules/scylladb/src/main/java/org/testcontainers/scylladb/ScyllaDBContainer.java new file mode 100644 index 00000000000..f181b8b9f00 --- /dev/null +++ b/modules/scylladb/src/main/java/org/testcontainers/scylladb/ScyllaDBContainer.java @@ -0,0 +1,109 @@ +package org.testcontainers.scylladb; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.net.InetSocketAddress; +import java.util.Optional; + +/** + * Testcontainers implementation for ScyllaDB. + *

+ * Supported image: {@code scylladb/scylla} + *

+ * Exposed ports: + *

    + *
  • CQL Port: 9042
  • + *
  • Shard Aware Port: 19042
  • + *
  • Alternator Port: 8000
  • + *
+ */ +public class ScyllaDBContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("scylladb/scylla"); + + private static final Integer CQL_PORT = 9042; + + private static final Integer SHARD_AWARE_PORT = 19042; + + private static final Integer ALTERNATOR_PORT = 8000; + + private static final String COMMAND = "--developer-mode=1 --overprovisioned=1"; + + private static final String CONTAINER_CONFIG_LOCATION = "/etc/scylla"; + + private boolean alternatorEnabled = false; + + private String configLocation; + + public ScyllaDBContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public ScyllaDBContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + withExposedPorts(CQL_PORT, SHARD_AWARE_PORT); + + withCommand(COMMAND); + waitingFor(Wait.forLogMessage(".*initialization completed..*", 1)); + } + + @Override + protected void configure() { + if (this.alternatorEnabled) { + addExposedPort(8000); + String newCommand = + COMMAND + " --alternator-port=" + ALTERNATOR_PORT + " --alternator-write-isolation=always"; + withCommand(newCommand); + } + + // Map (effectively replace) directory in Docker with the content of resourceLocation if resource location is + // not null. + Optional + .ofNullable(configLocation) + .map(MountableFile::forClasspathResource) + .ifPresent(mountableFile -> withCopyFileToContainer(mountableFile, CONTAINER_CONFIG_LOCATION)); + } + + public ScyllaDBContainer withConfigurationOverride(String configLocation) { + this.configLocation = configLocation; + return this; + } + + public ScyllaDBContainer withSsl(MountableFile certificate, MountableFile keyfile, MountableFile truststore) { + withCopyFileToContainer(certificate, "/etc/scylla/scylla.cer.pem"); + withCopyFileToContainer(keyfile, "/etc/scylla/scylla.key.pem"); + withCopyFileToContainer(truststore, "/etc/scylla/scylla.truststore"); + withEnv("SSL_CERTFILE", "/etc/scylla/scylla.cer.pem"); + return this; + } + + public ScyllaDBContainer withAlternator() { + this.alternatorEnabled = true; + return this; + } + + /** + * Retrieve an {@link InetSocketAddress} for connecting to the ScyllaDB container via the driver. + * + * @return A InetSocketAddress representation of this ScyllaDB container's host and port. + */ + public InetSocketAddress getContactPoint() { + return new InetSocketAddress(getHost(), getMappedPort(CQL_PORT)); + } + + public InetSocketAddress getShardAwareContactPoint() { + return new InetSocketAddress(getHost(), getMappedPort(SHARD_AWARE_PORT)); + } + + public String getAlternatorEndpoint() { + if (!this.alternatorEnabled) { + throw new IllegalStateException("Alternator is not enabled"); + } + return "http://" + getHost() + ":" + getMappedPort(ALTERNATOR_PORT); + } +} diff --git a/modules/scylladb/src/test/java/org/testcontainers/scylladb/ScyllaDBContainerTest.java b/modules/scylladb/src/test/java/org/testcontainers/scylladb/ScyllaDBContainerTest.java new file mode 100644 index 00000000000..d500aa74868 --- /dev/null +++ b/modules/scylladb/src/test/java/org/testcontainers/scylladb/ScyllaDBContainerTest.java @@ -0,0 +1,203 @@ +package org.testcontainers.scylladb; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.Container; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; +import software.amazon.awssdk.services.dynamodb.model.BillingMode; +import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest; +import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; +import software.amazon.awssdk.services.dynamodb.model.KeyType; +import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ScyllaDBContainerTest { + + private static final DockerImageName SCYLLADB_IMAGE = DockerImageName.parse("scylladb/scylla:6.2"); + + private static final String BASIC_QUERY = "SELECT release_version FROM system.local"; + + @Test + void testSimple() { + try ( // container { + ScyllaDBContainer scylladb = new ScyllaDBContainer("scylladb/scylla:6.2") + // } + ) { + scylladb.start(); + // session { + CqlSession session = CqlSession + .builder() + .addContactPoint(scylladb.getContactPoint()) + .withLocalDatacenter("datacenter1") + .build(); + // } + ResultSet resultSet = session.execute(BASIC_QUERY); + assertThat(resultSet.wasApplied()).isTrue(); + assertThat(resultSet.one().getString(0)).isNotNull(); + assertThat(session.getMetadata().getNodes().values()).hasSize(1); + } + } + + @Test + void testSimpleSsl() + throws NoSuchAlgorithmException, KeyStoreException, IOException, CertificateException, UnrecoverableKeyException, KeyManagementException { + try ( + // customConfiguration { + ScyllaDBContainer scylladb = new ScyllaDBContainer("scylladb/scylla:6.2") + .withConfigurationOverride("scylla-test-ssl") + .withSsl( + MountableFile.forClasspathResource("keys/scylla.cer.pem"), + MountableFile.forClasspathResource("keys/scylla.key.pem"), + MountableFile.forClasspathResource("keys/scylla.truststore") + ) + // } + ) { + // sslContext { + String testResourcesDir = getClass().getClassLoader().getResource("keys/").getPath(); + + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load( + Files.newInputStream(Paths.get(testResourcesDir + "scylla.keystore")), + "scylla".toCharArray() + ); + + KeyStore trustStore = KeyStore.getInstance("PKCS12"); + trustStore.load( + Files.newInputStream(Paths.get(testResourcesDir + "scylla.truststore")), + "scylla".toCharArray() + ); + + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance( + KeyManagerFactory.getDefaultAlgorithm() + ); + keyManagerFactory.init(keyStore, "scylla".toCharArray()); + + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm() + ); + trustManagerFactory.init(trustStore); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null); + // } + + scylladb.start(); + + CqlSession session = CqlSession + .builder() + .addContactPoint(scylladb.getContactPoint()) + .withLocalDatacenter("datacenter1") + .withSslContext(sslContext) + .build(); + ResultSet resultSet = session.execute(BASIC_QUERY); + assertThat(resultSet.wasApplied()).isTrue(); + assertThat(resultSet.one().getString(0)).isNotNull(); + assertThat(session.getMetadata().getNodes().values()).hasSize(1); + } + } + + @Test + void testSimpleSslCqlsh() throws IllegalStateException, InterruptedException, IOException { + try ( + ScyllaDBContainer scylladb = new ScyllaDBContainer(SCYLLADB_IMAGE) + .withConfigurationOverride("scylla-test-ssl") + .withSsl( + MountableFile.forClasspathResource("keys/scylla.cer.pem"), + MountableFile.forClasspathResource("keys/scylla.key.pem"), + MountableFile.forClasspathResource("keys/scylla.truststore") + ) + ) { + scylladb.start(); + + Container.ExecResult execResult = scylladb.execInContainer( + "cqlsh", + "--ssl", + "-e", + "select * from system_schema.keyspaces;" + ); + assertThat(execResult.getStdout()).contains("keyspace_name"); + } + } + + @Test + void testShardAwareness() { + try (ScyllaDBContainer scylladb = new ScyllaDBContainer(SCYLLADB_IMAGE)) { + scylladb.start(); + // shardAwarenessSession { + CqlSession session = CqlSession + .builder() + .addContactPoint(scylladb.getShardAwareContactPoint()) + .withLocalDatacenter("datacenter1") + .build(); + // } + ResultSet resultSet = session.execute("SELECT driver_name FROM system.clients"); + assertThat(resultSet.one().getString(0)).isNotNull(); + assertThat(session.getMetadata().getNodes().values()).hasSize(1); + } + } + + @Test + void testAlternator() { + try ( // alternator { + ScyllaDBContainer scylladb = new ScyllaDBContainer(SCYLLADB_IMAGE).withAlternator() + // } + ) { + scylladb.start(); + + // dynamodDbClient { + DynamoDbClient client = DynamoDbClient + .builder() + .endpointOverride(URI.create(scylladb.getAlternatorEndpoint())) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create("test", "test"))) + .region(Region.US_EAST_1) + .build(); + // } + client.createTable( + CreateTableRequest + .builder() + .tableName("demo_table") + .keySchema(KeySchemaElement.builder().attributeName("id").keyType(KeyType.HASH).build()) + .attributeDefinitions( + AttributeDefinition.builder().attributeName("id").attributeType(ScalarAttributeType.S).build() + ) + .billingMode(BillingMode.PAY_PER_REQUEST) + .build() + ); + assertThat(client.listTables().tableNames()).containsExactly(("demo_table")); + } + } + + @Test + void throwExceptionWhenAlternatorDisabled() { + try (ScyllaDBContainer scylladb = new ScyllaDBContainer(SCYLLADB_IMAGE)) { + scylladb.start(); + assertThatThrownBy(scylladb::getAlternatorEndpoint) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Alternator is not enabled"); + } + } +} diff --git a/modules/scylladb/src/test/resources/keys/node0.cer b/modules/scylladb/src/test/resources/keys/node0.cer new file mode 100644 index 00000000000..e69de29bb2d diff --git a/modules/scylladb/src/test/resources/keys/node0.p12 b/modules/scylladb/src/test/resources/keys/node0.p12 new file mode 100644 index 00000000000..e69de29bb2d diff --git a/modules/scylladb/src/test/resources/keys/scylla.cer.pem b/modules/scylladb/src/test/resources/keys/scylla.cer.pem new file mode 100644 index 00000000000..8f538448288 --- /dev/null +++ b/modules/scylladb/src/test/resources/keys/scylla.cer.pem @@ -0,0 +1,30 @@ +Bag Attributes + friendlyName: node0 + localKeyID: 54 69 6D 65 20 31 37 33 35 39 34 30 37 38 39 31 39 34 +subject=C=None, L=None, O=None, OU=None, CN=None +issuer=C=None, L=None, O=None, OU=None, CN=None +-----BEGIN CERTIFICATE----- +MIIEOzCCAqOgAwIBAgIIY4iVNsJSWiEwDQYJKoZIhvcNAQEMBQAwSzENMAsGA1UE +BhMETm9uZTENMAsGA1UEBxMETm9uZTENMAsGA1UEChMETm9uZTENMAsGA1UECxME +Tm9uZTENMAsGA1UEAxMETm9uZTAgFw0yNTAxMDMyMTM5MzFaGA8yMTI0MTIxMDIx +MzkzMVowSzENMAsGA1UEBhMETm9uZTENMAsGA1UEBxMETm9uZTENMAsGA1UEChME +Tm9uZTENMAsGA1UECxMETm9uZTENMAsGA1UEAxMETm9uZTCCAaIwDQYJKoZIhvcN +AQEBBQADggGPADCCAYoCggGBAJuC18n+jlDcmR8CWxSK3fR2t1Am8P7IK5FY3ky8 +vEJSCMh+GoiqXVq67zhpOJnlgvEEZIDJGzBmJ/nIZvQwIAMxs792fHIEpEI2GTpf +oaMf/9AAuPXuscg+5i4us1eVyVbrq3sREJ2NXHIPylcjtbwLjuepvmXTLp1d7oOJ +Ad0X0W3UN/uwrlV3NPBuVLjJiCvJijWrCv1lFTuIcclqs478ozllp8UfcwJ57OH2 +Hq1ee9Ex9y7HouDPfFzmMRp1/jEcb0xbefpdW3Am6P9AXQuw2JMempwt5KbrAE+Z +V1JnZCjSYSkspwid2bt5To/o60ypZUUswElasgAV/k8AxxDOkJGZusEqqVH7EFvk +h3FiY/jb9cM1t5eLcpjx0wA+GOuErW3dgH5/WYugY2iiYjP1IQTb8Pk+gfAvq+2p +SX3wISDCAh53j+aceUvNf+lItXsz66V9e+VH1xcOZcyO4gAMUVNYQFv/2wZ9knK4 +o30Aiqir1g2Hd5F/rWYNum+UbQIDAQABoyEwHzAdBgNVHQ4EFgQUqAWcYa3l/OHI +JACasy+bZUwHP9kwDQYJKoZIhvcNAQEMBQADggGBAJQo55VJd8aEv6uiC5bKdACo +M1GMvxWXUFzTdh2XKTOMF5GWwGJ3WRuW9o9wMZwXjvRihPfnx+DnfCCgZBOTGLXB +3ObsogR9rij4uquUIkGJsshggY2gO82NVD7dRwGClncwTI+/RU7qGUym4SEdg6GP +yfad3eTvqscQU1mNTxkaH0IDzPm0SWF8lcgGnrdHWlN+Nb8MJSHL5NFc9DA9pZck +5/4MG1X8Hsk/UT04ln+8VrhYFkxkDv4fSKlr65slrst5721J0j+VLEwnuEl1onpW +WHTTTIcOTDR5asrN9ZACCUsBxST8yfoJQ5G4HMO+UI1/1d928Ug6kHNWw2WR5FGG +pJVu9vpTdA01MNkSeCuZhaPe2XgZcNPyHXcVxslNvFFZ0FVt6pSIhtmZ+4a8dRsm +eU4NQ+PJ24En/8dErxaPqmi31wRZBg5Y9YlugJV4GQszCKHr0OYNK+Lpdq9dboUj +6lxX7+gshUgKMzunUl/rTvddG7e/WuZbi9IvmJ4MYw== +-----END CERTIFICATE----- diff --git a/modules/scylladb/src/test/resources/keys/scylla.key.pem b/modules/scylladb/src/test/resources/keys/scylla.key.pem new file mode 100644 index 00000000000..26ff1a8b80d --- /dev/null +++ b/modules/scylladb/src/test/resources/keys/scylla.key.pem @@ -0,0 +1,44 @@ +Bag Attributes + friendlyName: node0 + localKeyID: 54 69 6D 65 20 31 37 33 35 39 34 30 37 38 39 31 39 34 +Key Attributes: +-----BEGIN PRIVATE KEY----- +MIIG/AIBADANBgkqhkiG9w0BAQEFAASCBuYwggbiAgEAAoIBgQCbgtfJ/o5Q3Jkf +AlsUit30drdQJvD+yCuRWN5MvLxCUgjIfhqIql1auu84aTiZ5YLxBGSAyRswZif5 +yGb0MCADMbO/dnxyBKRCNhk6X6GjH//QALj17rHIPuYuLrNXlclW66t7ERCdjVxy +D8pXI7W8C47nqb5l0y6dXe6DiQHdF9Ft1Df7sK5VdzTwblS4yYgryYo1qwr9ZRU7 +iHHJarOO/KM5ZafFH3MCeezh9h6tXnvRMfcux6Lgz3xc5jEadf4xHG9MW3n6XVtw +Juj/QF0LsNiTHpqcLeSm6wBPmVdSZ2Qo0mEpLKcIndm7eU6P6OtMqWVFLMBJWrIA +Ff5PAMcQzpCRmbrBKqlR+xBb5IdxYmP42/XDNbeXi3KY8dMAPhjrhK1t3YB+f1mL +oGNoomIz9SEE2/D5PoHwL6vtqUl98CEgwgIed4/mnHlLzX/pSLV7M+ulfXvlR9cX +DmXMjuIADFFTWEBb/9sGfZJyuKN9AIqoq9YNh3eRf61mDbpvlG0CAwEAAQKCAYAT +SMt3qhB96I04cjNXPc0+ZoZe8yVJgwscEBgpDfKOitu5+SFTN0UyXiISLcIuG278 +cl4ANnAftVtZt0dFGr6thrlSkd/mx7qS12CTg45oyywO4DgPj1UOjvY+Xd4xi0qX +c8wlC72yu/ft0RV3bt83fXtwMPWCbQjHzQEp4JCRmUWISBvVI1jLEmhHNHdfHua6 +/1gbRaWsPJ/AbTAnGQtBPQUEth1y7W52rSX582pkd2YFUBvl+i2xkSlL3+PQ8zar +5giPYZrGh5pCu/bflAsBGZyRx9keSsRK/bzqE0xeRAwTOir2V6g7LbSKLC04xKNc +06/rHf1gslHNNOC3SjHvPyPfTJFHG9Tm+J5OoGo/Rr/W+GNgFMsFJ1fIq1VedpTt +ov4CBnBgew8uHTwCoiL6T7f/ttd206A6nhEZ9tWFf8v0o6+y6Z7g0VniU9IuLRLr +hXuKkxbBDZQRO8equlAKtbkqv6YFbGImmF/1YwP1/Ct1TR1BDM3m1UB6eez7BWEC +gcEAx2RL8dJCVbKoRMjsKqNNh0R3vIz0+S8PTi3yjFjhggUCWOzlwMVFv/y0ztGf +pj6Y41eaIdTwQu76uZra748Uj1Vwj5zAKXhb/THWoAidONFRj+qJ3ylDobrO5Fme +RiCFlIfjNc6wYQiGqSMXTF02O67to44G+4zsrz+syIZO3ANOR+uB+LUNqvFKL5Kk +BUDtU+r9poIoXkgYylzRb/6H0J+D0fcPGg+LHeRvp3DL6uueDN7eGXxdy7hF/q3L +DqHlAoHBAMepUZUe5m6h6wIYWoaXPwvSeuBSHWUiGEqoNCrA/1tBI49AOjfn6ccy +vu51ng/hEI/XpQ+QXvM/MNk3wyKe3HMjaPKiRbro9EFtva3pz3SrLoRHHzSGkzW3 +iTavg8RKo76Pz7MNEVqfkFn0pYr85EMIe4hmmrdR6nwd1oJY1CEMf4wllhWG+v1y +901xLisuRZFE/X4ASvyDyY0Nh+9Cfd+80QS9fpZwuCR+mHQvIpp89F/Ohqyhk9CU +HLncQD2f6QKBwBJZUX/UeJRIV6HU157o3kaXb2ljk1unEAKCyfJOb5o2ecvTKSV/ +Qfbz+3OY6Nc0pX8uXZnFbcLLGTmhXYp0IVE7bJtasnhegiCfyH97q3RCFv5md/+Y +XYfxl/59nMoZThGoG6mk9qhHT5UbDJbTcR028Nl/RXc6tcE+29isO2+VwktuCczo +ZHSZtdkA5qUxH2X8lxEOo0Zh3h4pQoDK7JavR0M4OCSOz5+VmQzQnYNl4WqPy+KO +hlcsAwz301rqXQKBwHkY2+9q924gbM4vgTBiqY19EqPdihCd1kfprwJDXl21q2Cm +HulrkqILyDwPQFf3NLlZnLZM5Rn5uKH2rTbhTWnUD0IiY9KSmhrY+ZNy3S2w6Zy3 +GlkcSkrpT6LIX039y0S4Ksw5X84sOzwkIweijLuPeIVpXetUFrlCy6jxQW/uCaox +3c6euLpiMVZaEBuGjBEo2+rBOLnhIKyZiVn3ZSr/dXK/j/ik0zrnQYYuVHmI0hsN +wycPNPzr6GReDuSRiQKBwChoS1Vvv49agWjyViIohGm6GHsY1Y1FNIqddHN5KgfA +LGZRm8JhlTBPX89KgWUpemDjRHw84vqF46Md9+eeuovr697/fEVQ1W4FWJs9JLej +2zmRlZqQgFnR6hdeeg1l7V8bPLR1zfl0R7+UkguP1xuI55fZc9H5icMCrOOCo1ug +vdBrhNl4Swzn+wTVY62J/GX86Rfeybvn+BJQW4RCuKFqcxqctPuR5i+wMOxKWZP3 +fMq1U6czbhYvEjp3Y42Exw== +-----END PRIVATE KEY----- diff --git a/modules/scylladb/src/test/resources/keys/scylla.keystore b/modules/scylladb/src/test/resources/keys/scylla.keystore new file mode 100644 index 00000000000..7f027beaf64 Binary files /dev/null and b/modules/scylladb/src/test/resources/keys/scylla.keystore differ diff --git a/modules/scylladb/src/test/resources/keys/scylla.truststore b/modules/scylladb/src/test/resources/keys/scylla.truststore new file mode 100644 index 00000000000..a798f8dcce4 Binary files /dev/null and b/modules/scylladb/src/test/resources/keys/scylla.truststore differ diff --git a/modules/scylladb/src/test/resources/logback-test.xml b/modules/scylladb/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/scylladb/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/scylladb/src/test/resources/scylla-test-ssl/scylla.yaml b/modules/scylladb/src/test/resources/scylla-test-ssl/scylla.yaml new file mode 100644 index 00000000000..7d79fabb70e --- /dev/null +++ b/modules/scylladb/src/test/resources/scylla-test-ssl/scylla.yaml @@ -0,0 +1,662 @@ +# Scylla storage config YAML + +####################################### +# This file is split to two sections: +# 1. Supported parameters +# 2. Unsupported parameters: reserved for future use or backwards +# compatibility. +# Scylla will only read and use the first segment +####################################### + +### Supported Parameters + +# The name of the cluster. This is mainly used to prevent machines in +# one logical cluster from joining another. +# It is recommended to change the default value when creating a new cluster. +# You can NOT modify this value for an existing cluster +#cluster_name: 'Test Cluster' + +# This defines the number of tokens randomly assigned to this node on the ring +# The more tokens, relative to other nodes, the larger the proportion of data +# that this node will store. You probably want all nodes to have the same number +# of tokens assuming they have equal hardware capability. +num_tokens: 256 + +# Directory where Scylla should store all its files, which are commitlog, +# data, hints, view_hints and saved_caches subdirectories. All of these +# subs can be overridden by the respective options below. +# If unset, the value defaults to /var/lib/scylla +# workdir: /var/lib/scylla + +# Directory where Scylla should store data on disk. +# data_file_directories: +# - /var/lib/scylla/data + +# commit log. when running on magnetic HDD, this should be a +# separate spindle than the data directories. +# commitlog_directory: /var/lib/scylla/commitlog + +# schema commit log. A special commitlog instance +# used for schema and system tables. +# When running on magnetic HDD, this should be a +# separate spindle than the data directories. +# schema_commitlog_directory: /var/lib/scylla/commitlog/schema + +# commitlog_sync may be either "periodic" or "batch." +# +# When in batch mode, Scylla won't ack writes until the commit log +# has been fsynced to disk. It will wait +# commitlog_sync_batch_window_in_ms milliseconds between fsyncs. +# This window should be kept short because the writer threads will +# be unable to do extra work while waiting. (You may need to increase +# concurrent_writes for the same reason.) +# +# commitlog_sync: batch +# commitlog_sync_batch_window_in_ms: 2 +# +# the other option is "periodic" where writes may be acked immediately +# and the CommitLog is simply synced every commitlog_sync_period_in_ms +# milliseconds. +commitlog_sync: periodic +commitlog_sync_period_in_ms: 10000 + +# The size of the individual commitlog file segments. A commitlog +# segment may be archived, deleted, or recycled once all the data +# in it (potentially from each columnfamily in the system) has been +# flushed to sstables. +# +# The default size is 32, which is almost always fine, but if you are +# archiving commitlog segments (see commitlog_archiving.properties), +# then you probably want a finer granularity of archiving; 8 or 16 MB +# is reasonable. +commitlog_segment_size_in_mb: 32 + +# The size of the individual schema commitlog file segments. +# +# The default size is 128, which is 4 times larger than the default +# size of the data commitlog. It's because the segment size puts +# a limit on the mutation size that can be written at once, and some +# schema mutation writes are much larger than average. +schema_commitlog_segment_size_in_mb: 128 + +# any class that implements the SeedProvider interface and has a +# constructor that takes a Map of parameters will do. +seed_provider: + # Addresses of hosts that are deemed contact points. + # Cassandra nodes use this list of hosts to find each other and learn + # the topology of the ring. You must change this if you are running + # multiple nodes! + - class_name: org.apache.cassandra.locator.SimpleSeedProvider + parameters: + # seeds is actually a comma-delimited list of addresses. + # Ex: ",," + - seeds: "172.17.0.3,127.0.0.1,172.17.0.2,172.17.0.4,172.17.0.5" + + +# Address to bind to and tell other Scylla nodes to connect to. +# You _must_ change this if you want multiple nodes to be able to communicate! +# +# If you leave broadcast_address (below) empty, then setting listen_address +# to 0.0.0.0 is wrong as other nodes will not know how to reach this node. +# If you set broadcast_address, then you can set listen_address to 0.0.0.0. +listen_address: localhost + +# Address to broadcast to other Scylla nodes +# Leaving this blank will set it to the same value as listen_address +# broadcast_address: 1.2.3.4 + + +# When using multiple physical network interfaces, set this to true to listen on broadcast_address +# in addition to the listen_address, allowing nodes to communicate in both interfaces. +# Ignore this property if the network configuration automatically routes between the public and private networks such as EC2. +# +# listen_on_broadcast_address: false + +# port for the CQL native transport to listen for clients on +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +# To disable the CQL native transport, remove this option and configure native_transport_port_ssl. +native_transport_port: 9042 + +# Like native_transport_port, but clients are forwarded to specific shards, based on the +# client-side port numbers. +native_shard_aware_transport_port: 19042 + +# Enabling native transport encryption in client_encryption_options allows you to either use +# encryption for the standard port or to use a dedicated, additional port along with the unencrypted +# standard native_transport_port. +# Enabling client encryption and keeping native_transport_port_ssl disabled will use encryption +# for native_transport_port. Setting native_transport_port_ssl to a different value +# from native_transport_port will use encryption for native_transport_port_ssl while +# keeping native_transport_port unencrypted. +#native_transport_port_ssl: 9142 + +# Like native_transport_port_ssl, but clients are forwarded to specific shards, based on the +# client-side port numbers. +#native_shard_aware_transport_port_ssl: 19142 + +# How long the coordinator should wait for read operations to complete +read_request_timeout_in_ms: 5000 + +# How long the coordinator should wait for writes to complete +write_request_timeout_in_ms: 2000 +# how long a coordinator should continue to retry a CAS operation +# that contends with other proposals for the same row +cas_contention_timeout_in_ms: 1000 + +# phi value that must be reached for a host to be marked down. +# most users should never need to adjust this. +# phi_convict_threshold: 8 + +# IEndpointSnitch. The snitch has two functions: +# - it teaches Scylla enough about your network topology to route +# requests efficiently +# - it allows Scylla to spread replicas around your cluster to avoid +# correlated failures. It does this by grouping machines into +# "datacenters" and "racks." Scylla will do its best not to have +# more than one replica on the same "rack" (which may not actually +# be a physical location) +# +# IF YOU CHANGE THE SNITCH AFTER DATA IS INSERTED INTO THE CLUSTER, +# YOU MUST RUN A FULL REPAIR, SINCE THE SNITCH AFFECTS WHERE REPLICAS +# ARE PLACED. +# +# Out of the box, Scylla provides +# - SimpleSnitch: +# Treats Strategy order as proximity. This can improve cache +# locality when disabling read repair. Only appropriate for +# single-datacenter deployments. +# - GossipingPropertyFileSnitch +# This should be your go-to snitch for production use. The rack +# and datacenter for the local node are defined in +# cassandra-rackdc.properties and propagated to other nodes via +# gossip. If cassandra-topology.properties exists, it is used as a +# fallback, allowing migration from the PropertyFileSnitch. +# - PropertyFileSnitch: +# Proximity is determined by rack and data center, which are +# explicitly configured in cassandra-topology.properties. +# - Ec2Snitch: +# Appropriate for EC2 deployments in a single Region. Loads Region +# and Availability Zone information from the EC2 API. The Region is +# treated as the datacenter, and the Availability Zone as the rack. +# Only private IPs are used, so this will not work across multiple +# Regions. +# - Ec2MultiRegionSnitch: +# Uses public IPs as broadcast_address to allow cross-region +# connectivity. (Thus, you should set seed addresses to the public +# IP as well.) You will need to open the storage_port or +# ssl_storage_port on the public IP firewall. (For intra-Region +# traffic, Scylla will switch to the private IP after +# establishing a connection.) +# - RackInferringSnitch: +# Proximity is determined by rack and data center, which are +# assumed to correspond to the 3rd and 2nd octet of each node's IP +# address, respectively. Unless this happens to match your +# deployment conventions, this is best used as an example of +# writing a custom Snitch class and is provided in that spirit. +# +# You can use a custom Snitch by setting this to the full class name +# of the snitch, which will be assumed to be on your classpath. +endpoint_snitch: SimpleSnitch + +# The address or interface to bind the native transport server to. +# +# Set rpc_address OR rpc_interface, not both. Interfaces must correspond +# to a single address, IP aliasing is not supported. +# +# Leaving rpc_address blank has the same effect as on listen_address +# (i.e. it will be based on the configured hostname of the node). +# +# Note that unlike listen_address, you can specify 0.0.0.0, but you must also +# set broadcast_rpc_address to a value other than 0.0.0.0. +# +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +# +# If you choose to specify the interface by name and the interface has an ipv4 and an ipv6 address +# you can specify which should be chosen using rpc_interface_prefer_ipv6. If false the first ipv4 +# address will be used. If true the first ipv6 address will be used. Defaults to false preferring +# ipv4. If there is only one address it will be selected regardless of ipv4/ipv6. +rpc_address: localhost +# rpc_interface: eth1 +# rpc_interface_prefer_ipv6: false + +# port for REST API server +api_port: 10000 + +# IP for the REST API server +api_address: 127.0.0.1 + +# Log WARN on any batch size exceeding this value. 128 kiB per batch by default. +# Caution should be taken on increasing the size of this threshold as it can lead to node instability. +batch_size_warn_threshold_in_kb: 128 + +# Fail any multiple-partition batch exceeding this value. 1 MiB (8x warn threshold) by default. +batch_size_fail_threshold_in_kb: 1024 + + # Authentication backend, identifying users + # Out of the box, Scylla provides org.apache.cassandra.auth.{AllowAllAuthenticator, + # PasswordAuthenticator}. + # + # - AllowAllAuthenticator performs no checks - set it to disable authentication. + # - PasswordAuthenticator relies on username/password pairs to authenticate + # users. It keeps usernames and hashed passwords in system_auth.credentials table. + # Please increase system_auth keyspace replication factor if you use this authenticator. + # - com.scylladb.auth.TransitionalAuthenticator requires username/password pair + # to authenticate in the same manner as PasswordAuthenticator, but improper credentials + # result in being logged in as an anonymous user. Use for upgrading clusters' auth. + # authenticator: AllowAllAuthenticator + + # Authorization backend, implementing IAuthorizer; used to limit access/provide permissions + # Out of the box, Scylla provides org.apache.cassandra.auth.{AllowAllAuthorizer, + # CassandraAuthorizer}. + # + # - AllowAllAuthorizer allows any action to any user - set it to disable authorization. + # - CassandraAuthorizer stores permissions in system_auth.permissions table. Please + # increase system_auth keyspace replication factor if you use this authorizer. + # - com.scylladb.auth.TransitionalAuthorizer wraps around the CassandraAuthorizer, using it for + # authorizing permission management. Otherwise, it allows all. Use for upgrading + # clusters' auth. + # authorizer: AllowAllAuthorizer + + # initial_token allows you to specify tokens manually. While you can use # it with + # vnodes (num_tokens > 1, above) -- in which case you should provide a + # comma-separated list -- it's primarily used when adding nodes # to legacy clusters + # that do not have vnodes enabled. + # initial_token: + + # RPC address to broadcast to drivers and other Scylla nodes. This cannot + # be set to 0.0.0.0. If left blank, this will be set to the value of + # rpc_address. If rpc_address is set to 0.0.0.0, broadcast_rpc_address must + # be set. + # broadcast_rpc_address: 1.2.3.4 + + # Uncomment to enable experimental features + # experimental_features: + # - udf + # - alternator-streams + # - broadcast-tables + # - keyspace-storage-options + + # The directory where hints files are stored if hinted handoff is enabled. + # hints_directory: /var/lib/scylla/hints + +# The directory where hints files are stored for materialized-view updates +# view_hints_directory: /var/lib/scylla/view_hints + +# See https://docs.scylladb.com/architecture/anti-entropy/hinted-handoff +# May either be "true" or "false" to enable globally, or contain a list +# of data centers to enable per-datacenter. +# hinted_handoff_enabled: DC1,DC2 +# hinted_handoff_enabled: true + +# this defines the maximum amount of time a dead host will have hints +# generated. After it has been dead this long, new hints for it will not be +# created until it has been seen alive and gone down again. +# max_hint_window_in_ms: 10800000 # 3 hours + + +# Validity period for permissions cache (fetching permissions can be an +# expensive operation depending on the authorizer, CassandraAuthorizer is +# one example). Defaults to 10000, set to 0 to disable. +# Will be disabled automatically for AllowAllAuthorizer. +# permissions_validity_in_ms: 10000 + +# Refresh interval for permissions cache (if enabled). +# After this interval, cache entries become eligible for refresh. Upon next +# access, an async reload is scheduled and the old value returned until it +# completes. If permissions_validity_in_ms is non-zero, then this also must have +# a non-zero value. Defaults to 2000. It's recommended to set this value to +# be at least 3 times smaller than the permissions_validity_in_ms. +# permissions_update_interval_in_ms: 2000 + +# The partitioner is responsible for distributing groups of rows (by +# partition key) across nodes in the cluster. You should leave this +# alone for new clusters. The partitioner can NOT be changed without +# reloading all data, so when upgrading you should set this to the +# same partitioner you were already using. +# +# Murmur3Partitioner is currently the only supported partitioner, +# +partitioner: org.apache.cassandra.dht.Murmur3Partitioner + +# Total space to use for commitlogs. +# +# If space gets above this value (it will round up to the next nearest +# segment multiple), Scylla will flush every dirty CF in the oldest +# segment and remove it. So a small total commitlog space will tend +# to cause more flush activity on less-active columnfamilies. +# +# A value of -1 (default) will automatically equate it to the total amount of memory +# available for Scylla. +commitlog_total_space_in_mb: -1 + +# TCP port, for commands and data +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +# storage_port: 7000 + +# SSL port, for encrypted communication. Unused unless enabled in +# encryption_options +# For security reasons, you should not expose this port to the internet. Firewall it if needed. +# ssl_storage_port: 7001 + +# listen_interface: eth0 +# listen_interface_prefer_ipv6: false + +# Whether to start the native transport server. +# Please note that the address on which the native transport is bound is the +# same as the rpc_address. The port however is different and specified below. +# start_native_transport: true + +# The maximum size of allowed frame. Frame (requests) larger than this will +# be rejected as invalid. The default is 256MB. +# native_transport_max_frame_size_in_mb: 256 + +# enable or disable keepalive on rpc/native connections +# rpc_keepalive: true + +# Set to true to have Scylla create a hard link to each sstable +# flushed or streamed locally in a backups/ subdirectory of the +# keyspace data. Removing these links is the operator's +# responsibility. +# incremental_backups: false + +# Whether or not to take a snapshot before each compaction. Be +# careful using this option, since Scylla won't clean up the +# snapshots for you. Mostly useful if you're paranoid when there +# is a data format change. +# snapshot_before_compaction: false + +# Whether or not a snapshot is taken of the data before keyspace truncation +# or dropping of column families. The STRONGLY advised default of true +# should be used to provide data safety. If you set this flag to false, you will +# lose data on truncation or drop. +# auto_snapshot: true + +# When executing a scan, within or across a partition, we need to keep the +# tombstones seen in memory so we can return them to the coordinator, which +# will use them to make sure other replicas also know about the deleted rows. +# With workloads that generate a lot of tombstones, this can cause performance +# problems and even exhaust the server heap. +# (http://www.datastax.com/dev/blog/cassandra-anti-patterns-queues-and-queue-like-datasets) +# Adjust the thresholds here if you understand the dangers and want to +# scan more tombstones anyway. These thresholds may also be adjusted at runtime +# using the StorageService mbean. +# tombstone_warn_threshold: 1000 +# tombstone_failure_threshold: 100000 + +# Granularity of the collation index of rows within a partition. +# Increase if your rows are large, or if you have a very large +# number of rows per partition. The competing goals are these: +# 1) a smaller granularity means more index entries are generated +# and looking up rows within the partition by collation column +# is faster +# 2) but, Scylla will keep the collation index in memory for hot +# rows (as part of the key cache), so a larger granularity means +# you can cache more hot rows +# column_index_size_in_kb: 64 + +# Auto-scaling of the promoted index prevents running out of memory +# when the promoted index grows too large (due to partitions with many rows +# vs. too small column_index_size_in_kb). When the serialized representation +# of the promoted index grows by this threshold, the desired block size +# for this partition (initialized to column_index_size_in_kb) +# is doubled, to decrease the sampling resolution by half. +# +# To disable promoted index auto-scaling, set the threshold to 0. +# column_index_auto_scale_threshold_in_kb: 10240 + +# Log a warning when writing partitions larger than this value +# compaction_large_partition_warning_threshold_mb: 1000 + +# Log a warning when writing rows larger than this value +# compaction_large_row_warning_threshold_mb: 10 + +# Log a warning when writing cells larger than this value +# compaction_large_cell_warning_threshold_mb: 1 + +# Log a warning when row number is larger than this value +# compaction_rows_count_warning_threshold: 100000 + +# Log a warning when writing a collection containing more elements than this value +# compaction_collection_elements_count_warning_threshold: 10000 + +# How long the coordinator should wait for seq or index scans to complete +# range_request_timeout_in_ms: 10000 +# How long the coordinator should wait for writes to complete +# counter_write_request_timeout_in_ms: 5000 +# How long a coordinator should continue to retry a CAS operation +# that contends with other proposals for the same row +# cas_contention_timeout_in_ms: 1000 +# How long the coordinator should wait for truncates to complete +# (This can be much longer, because unless auto_snapshot is disabled +# we need to flush first so we can snapshot before removing the data.) +# truncate_request_timeout_in_ms: 60000 +# The default timeout for other, miscellaneous operations +# request_timeout_in_ms: 10000 + +# Enable or disable inter-node encryption. +# You must also generate keys and provide the appropriate key and trust store locations and passwords. +# +# The available internode options are : all, none, dc, rack +# If set to dc scylla will encrypt the traffic between the DCs +# If set to rack scylla will encrypt the traffic between the racks +# +# SSL/TLS algorithm and ciphers used can be controlled by +# the priority_string parameter. Info on priority string +# syntax and values is available at: +# https://gnutls.org/manual/html_node/Priority-Strings.html +# +# The require_client_auth parameter allows you to +# restrict access to service based on certificate +# validation. Client must provide a certificate +# accepted by the used trust store to connect. +# +# server_encryption_options: +# internode_encryption: none +# certificate: conf/scylla.crt +# keyfile: conf/scylla.key +# truststore: +# certficate_revocation_list: +# require_client_auth: False +# priority_string: + +# enable or disable client/server encryption. +client_encryption_options: + enabled: true + certificate: /etc/scylla/scylla.cer.pem + keyfile: /etc/scylla/scylla.key.pem + truststore: /etc/scylla/scylla.truststore + truststore_password: scylla +# certficate_revocation_list: +# require_client_auth: False +# priority_string: + +# internode_compression controls whether traffic between nodes is +# compressed. +# can be: all - all traffic is compressed +# dc - traffic between different datacenters is compressed +# none - nothing is compressed. +# internode_compression: none + +# Enables inter-node traffic compression metrics (`scylla_rpc_compression_...`) +# and enables a new implementation of inter-node traffic compressors, +# capable of using zstd (in addition to the default lz4) +# and shared dictionaries. +# (Those features must still be enabled by other settings). +# Has minor CPU cost. +# +# internode_compression_enable_advanced: false + +# Enables training of shared compression dictionaries on inter-node traffic. +# New dictionaries are distributed throughout the cluster via Raft, +# and used to improve the effectiveness of inter-node traffic compression +# when `internode_compression_enable_advanced` is enabled. +# +# WARNING: this may leak unencrypted data to disk. The trained dictionaries +# contain randomly-selected pieces of data written to the cluster. +# When the Raft log is unencrypted, those pieces of data will be +# written to disk unencrypted. At the moment of writing, there is no +# way to encrypt the Raft log. +# This problem is tracked by https://github.com/scylladb/scylla-enterprise/issues/4717. +# +# Can be: never - Dictionaries aren't trained by this node. +# when_leader - New dictionaries are trained by this node only if +# it's the current Raft leader. +# always - Dictionaries are trained by this node unconditionally. +# +# For efficiency reasons, training shouldn't be enabled on more than one node. +# To enable it on a single node, one can let the cluster pick the trainer +# by setting `when_leader` on all nodes, or specify one manually by setting `always` +# on one node and `never` on others. +# +# rpc_dict_training_when: never + +# A number in range [0.0, 1.0] specifying the share of CPU which can be spent +# by this node on compressing inter-node traffic with zstd. +# +# Depending on the workload, enabling zstd might have a drastic negative +# effect on performance, so it shouldn't be done lightly. +# +# internode_compression_zstd_max_cpu_fraction: 0.0 + +# Enable or disable tcp_nodelay for inter-dc communication. +# Disabling it will result in larger (but fewer) network packets being sent, +# reducing overhead from the TCP protocol itself, at the cost of increasing +# latency if you block for cross-datacenter responses. +# inter_dc_tcp_nodelay: false + +# Relaxation of environment checks. +# +# Scylla places certain requirements on its environment. If these requirements are +# not met, performance and reliability can be degraded. +# +# These requirements include: +# - A filesystem with good support for asynchronous I/O (AIO). Currently, +# this means XFS. +# +# false: strict environment checks are in place; do not start if they are not met. +# true: relaxed environment checks; performance and reliability may degraade. +# +# developer_mode: false + + +# Idle-time background processing +# +# Scylla can perform certain jobs in the background while the system is otherwise idle, +# freeing processor resources when there is other work to be done. +# +# defragment_memory_on_idle: true +# +# prometheus port +# By default, Scylla opens prometheus API port on port 9180 +# setting the port to 0 will disable the prometheus API. +# prometheus_port: 9180 +# +# prometheus address +# Leaving this blank will set it to the same value as listen_address. +# This means that by default, Scylla listens to the prometheus API on the same +# listening address (and therefore network interface) used to listen for +# internal communication. If the monitoring node is not in this internal +# network, you can override prometheus_address explicitly - e.g., setting +# it to 0.0.0.0 to listen on all interfaces. +# prometheus_address: 1.2.3.4 + +# Distribution of data among cores (shards) within a node +# +# Scylla distributes data within a node among shards, using a round-robin +# strategy: +# [shard0] [shard1] ... [shardN-1] [shard0] [shard1] ... [shardN-1] ... +# +# Scylla versions 1.6 and below used just one repetition of the pattern; +# this interfered with data placement among nodes (vnodes). +# +# Scylla versions 1.7 and above use 4096 repetitions of the pattern; this +# provides for better data distribution. +# +# the value below is log (base 2) of the number of repetitions. +# +# Set to 0 to avoid rewriting all data when upgrading from Scylla 1.6 and +# below. +# +# Keep at 12 for new clusters. +murmur3_partitioner_ignore_msb_bits: 12 + +# Use on a new, parallel algorithm for performing aggregate queries. +# Set to `false` to fall-back to the old algorithm. +# enable_parallelized_aggregation: true + +# Time for which task manager task started internally is kept in memory after it completes. +# task_ttl_in_seconds: 0 + +# Time for which task manager task started by user is kept in memory after it completes. +# user_task_ttl_in_seconds: 3600 + +# In materialized views, restrictions are allowed only on the view's primary key columns. +# In old versions Scylla mistakenly allowed IS NOT NULL restrictions on columns which were not part +# of the view's primary key. These invalid restrictions were ignored. +# This option controls the behavior when someone tries to create a view with such invalid IS NOT NULL restrictions. +# +# Can be true, false, or warn. +# * `true`: IS NOT NULL is allowed only on the view's primary key columns, +# trying to use it on other columns will cause an error, as it should. +# * `false`: Scylla accepts IS NOT NULL restrictions on regular columns, but they're silently ignored. +# It's useful for backwards compatibility. +# * `warn`: The same as false, but there's a warning about invalid view restrictions. +# +# To preserve backwards compatibility on old clusters, Scylla's default setting is `warn`. +# New clusters have this option set to `true` by scylla.yaml (which overrides the default `warn`) +# to make sure that trying to create an invalid view causes an error. +strict_is_not_null_in_views: true + +# The Unix Domain Socket the node uses for maintenance socket. +# The possible options are: +# * ignore: the node will not open the maintenance socket, +# * workdir: the node will open the maintenance socket on the path /cql.m, +# where is a path defined by the workdir configuration option, +# * : the node will open the maintenance socket on the path . +maintenance_socket: ignore + +# If set to true, configuration parameters defined with LiveUpdate option can be updated in runtime with CQL +# by updating system.config virtual table. If we don't want any configuration parameter to be changed in runtime +# via CQL, this option should be set to false. This parameter doesn't impose any limits on other mechanisms updating +# configuration parameters in runtime, e.g. sending SIGHUP or using API. This option should be set to false +# e.g. for cloud users, for whom scylla's configuration should be changed only by support engineers. +# live_updatable_config_params_changeable_via_cql: true + +# **************** +# * GUARDRAILS * +# **************** + +# Guardrails to warn or fail when Replication Factor is smaller/greater than the threshold. +# Please note that the value of 0 is always allowed, +# which means that having no replication at all, i.e. RF = 0, is always valid. +# A guardrail value smaller than 0, e.g. -1, means that the guardrail is disabled. +# Commenting out a guardrail also means it is disabled. +# minimum_replication_factor_fail_threshold: -1 +# minimum_replication_factor_warn_threshold: 3 +# maximum_replication_factor_warn_threshold: -1 +# maximum_replication_factor_fail_threshold: -1 + +# Guardrails to warn about or disallow creating a keyspace with specific replication strategy. +# Each of these 2 settings is a list storing replication strategies considered harmful. +# The replication strategies to choose from are: +# 1) SimpleStrategy, +# 2) NetworkTopologyStrategy, +# 3) LocalStrategy, +# 4) EverywhereStrategy +# +# replication_strategy_warn_list: +# - SimpleStrategy +# replication_strategy_fail_list: + +# Enable tablets for new keyspaces. +# When enabled, newly created keyspaces will have tablets enabled by default. +# That can be explicitly disabled in the CREATE KEYSPACE query +# by using the `tablets = {'enabled': false}` replication option. +# +# Correspondingly, when disabled, newly created keyspaces will use vnodes +# unless tablets are explicitly enabled in the CREATE KEYSPACE query +# by using the `tablets = {'enabled': true}` replication option. +# +# Note that creating keyspaces with tablets enabled or disabled is irreversible. +# The `tablets` option cannot be changed using `ALTER KEYSPACE`. +enable_tablets: true diff --git a/modules/selenium/build.gradle b/modules/selenium/build.gradle new file mode 100644 index 00000000000..79b525b6185 --- /dev/null +++ b/modules/selenium/build.gradle @@ -0,0 +1,18 @@ +description = "Testcontainers :: Selenium" + +dependencies { + api project(':testcontainers') + + provided 'org.seleniumhq.selenium:selenium-remote-driver:4.10.0' + provided 'org.seleniumhq.selenium:selenium-chrome-driver:4.10.0' + + testImplementation platform('org.seleniumhq.selenium:selenium-bom:4.13.0') + testImplementation 'org.seleniumhq.selenium:selenium-firefox-driver' + testImplementation 'org.seleniumhq.selenium:selenium-edge-driver' + testImplementation 'org.seleniumhq.selenium:selenium-support' + + testImplementation 'org.mortbay.jetty:jetty:6.1.26' + testImplementation project(':testcontainers-nginx') + + compileOnly 'org.jetbrains:annotations:26.1.0' +} diff --git a/modules/selenium/pom.xml b/modules/selenium/pom.xml deleted file mode 100644 index 51b1ab8ae88..00000000000 --- a/modules/selenium/pom.xml +++ /dev/null @@ -1,86 +0,0 @@ - - - 4.0.0 - - - org.testcontainers - testcontainers-parent - 1.2.1-SNAPSHOT - ../../pom.xml - - - selenium - TestContainers :: Selenium - - - - ${project.groupId} - testcontainers - ${project.version} - - - - com.google.guava - guava - 18.0 - provided - - - - - org.seleniumhq.selenium - selenium-remote-driver - 2.52.0 - provided - - - - - org.mortbay.jetty - jetty - 6.1.25 - - - - - - - maven-shade-plugin - 2.4.3 - - - package - - shade - - - - - - - com.google.common - org.testcontainers.shaded.com.google.common - - - - - org.bouncycastle:* - - META-INF/*.SF - META-INF/*.DSA - META-INF/*.RSA - - - - - - com.google.guava:* - - - true - false - - - - - \ No newline at end of file diff --git a/modules/selenium/src/main/java/org/testcontainers/containers/BrowserWebDriverContainer.java b/modules/selenium/src/main/java/org/testcontainers/containers/BrowserWebDriverContainer.java index 1798ec41116..53ee8ba5577 100644 --- a/modules/selenium/src/main/java/org/testcontainers/containers/BrowserWebDriverContainer.java +++ b/modules/selenium/src/main/java/org/testcontainers/containers/BrowserWebDriverContainer.java @@ -1,89 +1,205 @@ package org.testcontainers.containers; -import com.google.common.util.concurrent.Uninterruptibles; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.AccessMode; +import com.github.dockerjava.api.model.Bind; +import com.github.dockerjava.api.model.Volume; +import com.google.common.collect.ImmutableSet; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.SystemUtils; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.junit.runner.Description; -import org.openqa.selenium.remote.BrowserType; +import org.openqa.selenium.Capabilities; +import org.openqa.selenium.chrome.ChromeOptions; import org.openqa.selenium.remote.DesiredCapabilities; import org.openqa.selenium.remote.RemoteWebDriver; +import org.rnorth.ducttape.timeouts.Timeouts; import org.rnorth.ducttape.unreliables.Unreliables; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.testcontainers.containers.VncRecordingContainer.VncRecordingFormat; import org.testcontainers.containers.traits.LinkableContainer; -import org.testcontainers.containers.traits.VncService; +import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.lifecycle.TestDescription; +import org.testcontainers.lifecycle.TestLifecycleAware; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; import java.io.File; +import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Date; +import java.nio.file.Files; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import static com.google.common.base.Preconditions.checkState; /** * A chrome/firefox/custom container based on SeleniumHQ's standalone container sets. *

- * The container should expose Selenium remote control protocol and VNC. + * Supported images: {@code selenium/standalone-chrome}, {@code selenium/standalone-firefox}, + * {@code selenium/standalone-edge}, {@code selenium/standalone-chrome-debug}, {@code selenium/standalone-firefox-debug} + *

+ * Exposed ports: 4444 + * + * @deprecated use {@link org.testcontainers.selenium.BrowserWebDriverContainer} instead. */ -public class BrowserWebDriverContainer> extends GenericContainer implements VncService, LinkableContainer { +@Deprecated +public class BrowserWebDriverContainer> + extends GenericContainer + implements LinkableContainer, TestLifecycleAware { + + private static final DockerImageName CHROME_IMAGE = DockerImageName.parse("selenium/standalone-chrome"); + + private static final DockerImageName FIREFOX_IMAGE = DockerImageName.parse("selenium/standalone-firefox"); + + private static final DockerImageName EDGE_IMAGE = DockerImageName.parse("selenium/standalone-edge"); + + private static final DockerImageName CHROME_DEBUG_IMAGE = DockerImageName.parse("selenium/standalone-chrome-debug"); - private static final String CHROME_IMAGE = "selenium/standalone-chrome-debug:%s"; - private static final String FIREFOX_IMAGE = "selenium/standalone-firefox-debug:%s"; + private static final DockerImageName FIREFOX_DEBUG_IMAGE = DockerImageName.parse( + "selenium/standalone-firefox-debug" + ); + + private static final DockerImageName[] COMPATIBLE_IMAGES = new DockerImageName[] { + CHROME_IMAGE, + FIREFOX_IMAGE, + EDGE_IMAGE, + CHROME_DEBUG_IMAGE, + FIREFOX_DEBUG_IMAGE, + }; private static final String DEFAULT_PASSWORD = "secret"; + private static final int SELENIUM_PORT = 4444; + private static final int VNC_PORT = 5900; + private static final String NO_PROXY_KEY = "no_proxy"; + + private static final String TC_TEMP_DIR_PREFIX = "tc"; + @Nullable - private DesiredCapabilities desiredCapabilities; - private boolean customImageNameIsSet = false; + private Capabilities capabilities; + + private DockerImageName customImageName = null; @Nullable private RemoteWebDriver driver; + private VncRecordingMode recordingMode = VncRecordingMode.RECORD_FAILING; - private File vncRecordingDirectory = new File("/tmp"); - private final Collection currentVncRecordings = new ArrayList<>(); + private VncRecordingFormat recordingFormat; + + private RecordingFileFactory recordingFileFactory; + + private File vncRecordingDirectory; + + private VncRecordingContainer vncRecordingContainer = null; private static final Logger LOGGER = LoggerFactory.getLogger(BrowserWebDriverContainer.class); - private static final SimpleDateFormat filenameDateFormat = new SimpleDateFormat("YYYYMMdd-HHmmss"); - /** - */ public BrowserWebDriverContainer() { + super(); + this.waitStrategy = getDefaultWaitStrategy(); + this.withRecordingFileFactory(new DefaultRecordingFileFactory()); } /** * Constructor taking a specific webdriver container name and tag - * @param dockerImageName + * @param dockerImageName Name of the selenium docker image */ public BrowserWebDriverContainer(String dockerImageName) { - super.setDockerImageName(dockerImageName); - this.customImageNameIsSet = true; + this(DockerImageName.parse(dockerImageName)); + } + + /** + * Constructor taking a specific webdriver container name and tag + * @param dockerImageName Name of the selenium docker image + */ + public BrowserWebDriverContainer(DockerImageName dockerImageName) { + super(dockerImageName); + // we assert compatibility with the chrome/firefox/edge image later, after capabilities are processed + + this.waitStrategy = getDefaultWaitStrategy(); + + this.withRecordingFileFactory(new DefaultRecordingFileFactory()); + + this.customImageName = dockerImageName; + // We have to force SKIP mode for the recording by default because we don't know if the image has VNC or not + recordingMode = VncRecordingMode.SKIP; } + public SELF withCapabilities(Capabilities capabilities) { + this.capabilities = capabilities; + return self(); + } - public SELF withDesiredCapabilities(DesiredCapabilities desiredCapabilities) { - this.desiredCapabilities = desiredCapabilities; + /** + * @deprecated Use withCapabilities(Capabilities capabilities) instead: + * withCapabilities(new FirefoxOptions()) + * + * @param capabilities DesiredCapabilities + * @return SELF + * */ + @Deprecated + public SELF withDesiredCapabilities(DesiredCapabilities capabilities) { + this.capabilities = capabilities; return self(); } + @NotNull @Override - protected Integer getLivenessCheckPort() { - return getMappedPort(SELENIUM_PORT); + protected Set getLivenessCheckPorts() { + Integer seleniumPort = getMappedPort(SELENIUM_PORT); + if (recordingMode == VncRecordingMode.SKIP) { + return ImmutableSet.of(seleniumPort); + } else { + return ImmutableSet.of(seleniumPort, getMappedPort(VNC_PORT)); + } } @Override protected void configure() { + String seleniumVersion = SeleniumUtils.determineClasspathSeleniumVersion(); + + if (recordingMode != VncRecordingMode.SKIP) { + if (vncRecordingDirectory == null) { + try { + vncRecordingDirectory = Files.createTempDirectory(TC_TEMP_DIR_PREFIX).toFile(); + } catch (IOException e) { + // should never happen as per javadoc, since we use valid prefix + logger().error("Exception while trying to create temp directory", e); + throw new ContainerLaunchException("Exception while trying to create temp directory", e); + } + } + + if (getNetwork() == null) { + withNetwork(Network.SHARED); + } + + vncRecordingContainer = + new VncRecordingContainer(this) + .withVncPassword(DEFAULT_PASSWORD) + .withVncPort(VNC_PORT) + .withVideoFormat(recordingFormat); + } - checkState(desiredCapabilities != null); - if (! customImageNameIsSet) { - super.setDockerImageName(getImageForCapabilities(desiredCapabilities)); + if (customImageName != null) { + customImageName.assertCompatibleWith(COMPATIBLE_IMAGES); + super.setDockerImageName(customImageName.asCanonicalNameString()); + } else { + DockerImageName standardImageForCapabilities = getStandardImageForCapabilities( + capabilities, + seleniumVersion + ); + super.setDockerImageName(standardImageForCapabilities.asCanonicalNameString()); } String timeZone = System.getProperty("user.timezone"); @@ -94,74 +210,100 @@ protected void configure() { addExposedPorts(SELENIUM_PORT, VNC_PORT); addEnv("TZ", timeZone); - addEnv("no_proxy", "localhost"); + + if (!getEnvMap().containsKey(NO_PROXY_KEY)) { + addEnv(NO_PROXY_KEY, "localhost"); + } + setCommand("/opt/bin/entry_point.sh"); + if (getShmSize() == null) { + if (SystemUtils.IS_OS_WINDOWS) { + withSharedMemorySize(512 * FileUtils.ONE_MB); + } else { + this.getBinds().add(new Bind("/dev/shm", new Volume("/dev/shm"), AccessMode.rw)); + } + } + /* * Some unreliability of the selenium browser containers has been observed, so allow multiple attempts to start. */ setStartupAttempts(3); } - public static String getImageForCapabilities(DesiredCapabilities desiredCapabilities) { + /** + * @param capabilities a {@link Capabilities} object for either Chrome or Firefox + * @param seleniumVersion the version of selenium in use + * @return an image name for the default standalone Docker image for the appropriate browser + * + * @deprecated note that this method is deprecated and may be removed in the future. The no-args + * {@link BrowserWebDriverContainer#BrowserWebDriverContainer()} combined with the + * {@link BrowserWebDriverContainer#withCapabilities(Capabilities)} method should be considered. A decision on + * removal of this deprecated method will be taken at a future date. + */ + @Deprecated + public static String getDockerImageForCapabilities(Capabilities capabilities, String seleniumVersion) { + return getStandardImageForCapabilities(capabilities, seleniumVersion).asCanonicalNameString(); + } - String seleniumVersion = SeleniumUtils.determineClasspathSeleniumVersion(); + private static DockerImageName getStandardImageForCapabilities(Capabilities capabilities, String seleniumVersion) { + String browserName = capabilities == null ? BrowserType.CHROME : capabilities.getBrowserName(); + boolean supportsVncWithoutDebugImage = new ComparableVersion(seleniumVersion).isGreaterThanOrEqualTo("4"); - String browserName = desiredCapabilities.getBrowserName(); switch (browserName) { case BrowserType.CHROME: - return String.format(CHROME_IMAGE, seleniumVersion); + return (supportsVncWithoutDebugImage ? CHROME_IMAGE : CHROME_DEBUG_IMAGE).withTag(seleniumVersion); case BrowserType.FIREFOX: - return String.format(FIREFOX_IMAGE, seleniumVersion); + return (supportsVncWithoutDebugImage ? FIREFOX_IMAGE : FIREFOX_DEBUG_IMAGE).withTag(seleniumVersion); + case BrowserType.EDGE: + if (supportsVncWithoutDebugImage) { + return EDGE_IMAGE.withTag(seleniumVersion); + } + throw new UnsupportedOperationException( + "For browser 'MicrosoftEdge' selenium version must be 4 or higher;" + + "docker images are available from there upwards;" + + "provided version: '" + + seleniumVersion + + "'" + ); default: - throw new UnsupportedOperationException("Browser name must be 'chrome' or 'firefox'; provided '" + browserName + "' is not supported"); + throw new UnsupportedOperationException( + "Browser name must be 'chrome', 'firefox' or 'MicrosoftEdge';" + + "provided '" + + browserName + + "' is not supported" + ); } } public URL getSeleniumAddress() { try { - return new URL("http", getContainerIpAddress(), getMappedPort(SELENIUM_PORT), "/wd/hub"); + return new URL("http", getHost(), getMappedPort(SELENIUM_PORT), "/wd/hub"); } catch (MalformedURLException e) { - e.printStackTrace();// TODO + e.printStackTrace(); // TODO return null; } } - @Override public String getVncAddress() { - return "vnc://vnc:secret@" + getContainerIpAddress() + ":" + getMappedPort(VNC_PORT); + return "vnc://vnc:secret@" + getHost() + ":" + getMappedPort(VNC_PORT); } - @Override + @Deprecated public String getPassword() { return DEFAULT_PASSWORD; } - @Override + @Deprecated public int getPort() { return VNC_PORT; } @Override - protected void waitUntilContainerStarted() { - // Repeatedly try and open a webdriver session - - AtomicInteger backoff = new AtomicInteger(1000); - - driver = Unreliables.retryUntilSuccess(120, TimeUnit.SECONDS, () -> { - Uninterruptibles.sleepUninterruptibly(backoff.getAndUpdate(current -> (int)(current * 1.5)), TimeUnit.MILLISECONDS); - RemoteWebDriver driver = new RemoteWebDriver(getSeleniumAddress(), desiredCapabilities); - driver.getCurrentUrl(); - - logger().info("Obtained a connection to container ({})", BrowserWebDriverContainer.this.getSeleniumAddress()); - return driver; - }); - - if (recordingMode != VncRecordingMode.SKIP) { + protected void containerIsStarted(InspectContainerResponse containerInfo) { + if (vncRecordingContainer != null) { LOGGER.debug("Starting VNC recording"); - VncRecordingSidekickContainer recordingSidekickContainer = new VncRecordingSidekickContainer<>(this); - recordingSidekickContainer.start(); - currentVncRecordings.add(recordingSidekickContainer); + vncRecordingContainer.start(); } } @@ -172,49 +314,89 @@ protected void waitUntilContainerStarted() { * class (if used as a @ClassRule) * * @return a new Remote Web Driver instance + * @deprecated use {@link #getSeleniumAddress()} instead */ - public RemoteWebDriver getWebDriver() { + @Deprecated + public synchronized RemoteWebDriver getWebDriver() { + if (driver == null) { + if (capabilities == null) { + logger() + .warn( + "No capabilities provided - this will cause an exception in future versions. Falling back to ChromeOptions" + ); + capabilities = new ChromeOptions(); + } + + driver = + Unreliables.retryUntilSuccess( + 30, + TimeUnit.SECONDS, + () -> { + return Timeouts.getWithTimeout( + 10, + TimeUnit.SECONDS, + () -> { + return new RemoteWebDriver(getSeleniumAddress(), capabilities); + } + ); + } + ); + } return driver; } @Override - protected void failed(Throwable e, Description description) { - - switch (recordingMode) { - case RECORD_FAILING: - case RECORD_ALL: - stopAndRetainRecording(description); - break; - } - currentVncRecordings.clear(); + public void afterTest(TestDescription description, Optional throwable) { + retainRecordingIfNeeded(description.getFilesystemFriendlyName(), !throwable.isPresent()); } @Override - protected void succeeded(Description description) { - - switch (recordingMode) { - case RECORD_ALL: - stopAndRetainRecording(description); - break; + public void stop() { + if (driver != null) { + try { + driver.quit(); + } catch (Exception e) { + LOGGER.debug("Failed to quit the driver", e); + } + driver = null; } - currentVncRecordings.clear(); - } - @Override - protected void finished(Description description) { - if (driver != null) { - driver.quit(); + if (vncRecordingContainer != null) { + try { + vncRecordingContainer.stop(); + } catch (Exception e) { + LOGGER.debug("Failed to stop vncRecordingContainer", e); + } + vncRecordingContainer = null; } - this.stop(); + + super.stop(); } - private void stopAndRetainRecording(Description description) { - File recordingFile = new File(vncRecordingDirectory, "recording-" + filenameDateFormat.format(new Date()) + ".flv"); + private void retainRecordingIfNeeded(String prefix, boolean succeeded) { + final boolean shouldRecord; + switch (recordingMode) { + case RECORD_ALL: + shouldRecord = true; + break; + case RECORD_FAILING: + shouldRecord = !succeeded; + break; + default: + shouldRecord = false; + break; + } - LOGGER.info("Screen recordings for test {} will be stored at: {}", description.getDisplayName(), recordingFile); + if (shouldRecord) { + File recordingFile = recordingFileFactory.recordingFileForTest( + vncRecordingDirectory, + prefix, + succeeded, + vncRecordingContainer.getVideoFormat() + ); + LOGGER.info("Screen recordings for test {} will be stored at: {}", prefix, recordingFile); - for (VncRecordingSidekickContainer container : currentVncRecordings) { - container.stopAndRetainRecording(recordingFile); + vncRecordingContainer.saveRecordingToFile(recordingFile); } } @@ -225,22 +407,60 @@ private void stopAndRetainRecording(Description description) { * @param otherContainer the container rule to link to * @param alias the alias (hostname) that this other container should be referred to by * @return this + * + * @deprecated Links are deprecated (see #465). Please use {@link Network} features instead. */ + @Deprecated public SELF withLinkToContainer(LinkableContainer otherContainer, String alias) { addLink(otherContainer, alias); return self(); } public SELF withRecordingMode(VncRecordingMode recordingMode, File vncRecordingDirectory) { + return withRecordingMode(recordingMode, vncRecordingDirectory, null); + } + + public SELF withRecordingMode( + VncRecordingMode recordingMode, + File vncRecordingDirectory, + VncRecordingFormat recordingFormat + ) { this.recordingMode = recordingMode; this.vncRecordingDirectory = vncRecordingDirectory; + this.recordingFormat = recordingFormat; + return self(); + } + + public SELF withRecordingFileFactory(RecordingFileFactory recordingFileFactory) { + this.recordingFileFactory = recordingFileFactory; return self(); } + private WaitStrategy getDefaultWaitStrategy() { + final WaitStrategy logWaitStrategy = new LogMessageWaitStrategy() + .withRegEx( + ".*(RemoteWebDriver instances should connect to|Selenium Server is up and running|Started Selenium Standalone).*\n" + ) + .withStartupTimeout(Duration.of(60, ChronoUnit.SECONDS)); + + return new WaitAllStrategy() + .withStrategy(logWaitStrategy) + .withStrategy(new HostPortWaitStrategy()) + .withStartupTimeout(Duration.of(60, ChronoUnit.SECONDS)); + } public enum VncRecordingMode { - SKIP, RECORD_ALL, RECORD_FAILING + SKIP, + RECORD_ALL, + RECORD_FAILING, } + private static class BrowserType { + + private static final String CHROME = "chrome"; + private static final String FIREFOX = "firefox"; + + private static final String EDGE = "MicrosoftEdge"; + } } diff --git a/modules/selenium/src/main/java/org/testcontainers/containers/DefaultRecordingFileFactory.java b/modules/selenium/src/main/java/org/testcontainers/containers/DefaultRecordingFileFactory.java new file mode 100644 index 00000000000..3815f5de3e1 --- /dev/null +++ b/modules/selenium/src/main/java/org/testcontainers/containers/DefaultRecordingFileFactory.java @@ -0,0 +1,44 @@ +package org.testcontainers.containers; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.Date; + +public class DefaultRecordingFileFactory implements RecordingFileFactory { + + private static final SimpleDateFormat filenameDateFormat = new SimpleDateFormat("YYYYMMdd-HHmmss"); + + private static final String PASSED = "PASSED"; + + private static final String FAILED = "FAILED"; + + private static final String FILENAME_FORMAT = "%s-%s-%s.%s"; + + @Override + public File recordingFileForTest(File vncRecordingDirectory, String prefix, boolean succeeded) { + return recordingFileForTest( + vncRecordingDirectory, + prefix, + succeeded, + VncRecordingContainer.DEFAULT_RECORDING_FORMAT + ); + } + + @Override + public File recordingFileForTest( + File vncRecordingDirectory, + String prefix, + boolean succeeded, + VncRecordingContainer.VncRecordingFormat recordingFormat + ) { + final String resultMarker = succeeded ? PASSED : FAILED; + final String fileName = String.format( + FILENAME_FORMAT, + resultMarker, + prefix, + filenameDateFormat.format(new Date()), + recordingFormat.getFilenameExtension() + ); + return new File(vncRecordingDirectory, fileName); + } +} diff --git a/modules/selenium/src/main/java/org/testcontainers/containers/RecordingFileFactory.java b/modules/selenium/src/main/java/org/testcontainers/containers/RecordingFileFactory.java new file mode 100644 index 00000000000..9f3795f7cb8 --- /dev/null +++ b/modules/selenium/src/main/java/org/testcontainers/containers/RecordingFileFactory.java @@ -0,0 +1,18 @@ +package org.testcontainers.containers; + +import org.testcontainers.containers.VncRecordingContainer.VncRecordingFormat; + +import java.io.File; + +public interface RecordingFileFactory { + default File recordingFileForTest( + File vncRecordingDirectory, + String prefix, + boolean succeeded, + VncRecordingFormat recordingFormat + ) { + return recordingFileForTest(vncRecordingDirectory, prefix, succeeded); + } + + File recordingFileForTest(File vncRecordingDirectory, String prefix, boolean succeeded); +} diff --git a/modules/selenium/src/main/java/org/testcontainers/containers/SeleniumUtils.java b/modules/selenium/src/main/java/org/testcontainers/containers/SeleniumUtils.java index 9c0fd822a66..4fc0765cc86 100644 --- a/modules/selenium/src/main/java/org/testcontainers/containers/SeleniumUtils.java +++ b/modules/selenium/src/main/java/org/testcontainers/containers/SeleniumUtils.java @@ -38,33 +38,55 @@ public static String determineClasspathSeleniumVersion() { Manifest manifest = new Manifest(); manifest.read(is); - Attributes buildInfo = manifest.getAttributes("Build-Info"); - if (buildInfo != null) { - String seleniumVersion = buildInfo.getValue("Selenium-Version"); - - if (seleniumVersion != null) { - seleniumVersions.add(seleniumVersion); - LOGGER.info("Selenium API version {} detected on classpath", seleniumVersion); - } + String seleniumVersion = getSeleniumVersionFromManifest(manifest); + if (seleniumVersion != null) { + seleniumVersions.add(seleniumVersion); + LOGGER.info("Selenium API version {} detected on classpath", seleniumVersion); } } - } - } catch (Exception e) { LOGGER.debug("Failed to determine Selenium-Version from selenium-api JAR Manifest", e); } if (seleniumVersions.size() == 0) { - LOGGER.warn("Failed to determine Selenium version from classpath - will use default version of {}", DEFAULT_SELENIUM_VERSION); + LOGGER.warn( + "Failed to determine Selenium version from classpath - will use default version of {}", + DEFAULT_SELENIUM_VERSION + ); return DEFAULT_SELENIUM_VERSION; } String foundVersion = seleniumVersions.iterator().next(); if (seleniumVersions.size() > 1) { - LOGGER.warn("Multiple versions of Selenium API found on classpath - will select {}, but this may not be reliable", foundVersion); + LOGGER.warn( + "Multiple versions of Selenium API found on classpath - will select {}, but this may not be reliable", + foundVersion + ); } return foundVersion; } + + /** + * Read Manifest to get Selenium Version. + * @param manifest manifest + * @return Selenium Version detected + */ + public static String getSeleniumVersionFromManifest(Manifest manifest) { + String seleniumVersion = null; + Attributes buildInfo = manifest.getAttributes("Build-Info"); + if (buildInfo != null) { + seleniumVersion = buildInfo.getValue("Selenium-Version"); + } + + // Compatibility Selenium > 3.X + if (seleniumVersion == null) { + Attributes seleniumInfo = manifest.getAttributes("Selenium"); + if (seleniumInfo != null) { + seleniumVersion = seleniumInfo.getValue("Selenium-Version"); + } + } + return seleniumVersion; + } } diff --git a/modules/selenium/src/main/java/org/testcontainers/selenium/BrowserWebDriverContainer.java b/modules/selenium/src/main/java/org/testcontainers/selenium/BrowserWebDriverContainer.java new file mode 100644 index 00000000000..97ac23f5d55 --- /dev/null +++ b/modules/selenium/src/main/java/org/testcontainers/selenium/BrowserWebDriverContainer.java @@ -0,0 +1,283 @@ +package org.testcontainers.selenium; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.AccessMode; +import com.github.dockerjava.api.model.Bind; +import com.github.dockerjava.api.model.Volume; +import com.google.common.collect.ImmutableSet; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.SystemUtils; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.DefaultRecordingFileFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.RecordingFileFactory; +import org.testcontainers.containers.VncRecordingContainer; +import org.testcontainers.containers.VncRecordingContainer.VncRecordingFormat; +import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.lifecycle.TestDescription; +import org.testcontainers.lifecycle.TestLifecycleAware; +import org.testcontainers.utility.DockerImageName; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Files; +import java.time.Duration; +import java.util.Optional; +import java.util.Set; + +/** + * A chrome/firefox/custom container based on SeleniumHQ's standalone container sets. + *

+ * Supported images: {@code selenium/standalone-chrome}, {@code selenium/standalone-firefox}, + * {@code selenium/standalone-edge}, {@code selenium/standalone-chrome-debug}, {@code selenium/standalone-firefox-debug} + *

+ * Exposed ports: 4444 + */ +public class BrowserWebDriverContainer + extends GenericContainer + implements TestLifecycleAware { + + private static final DockerImageName CHROME_IMAGE = DockerImageName.parse("selenium/standalone-chrome"); + + private static final DockerImageName FIREFOX_IMAGE = DockerImageName.parse("selenium/standalone-firefox"); + + private static final DockerImageName EDGE_IMAGE = DockerImageName.parse("selenium/standalone-edge"); + + private static final DockerImageName CHROME_DEBUG_IMAGE = DockerImageName.parse("selenium/standalone-chrome-debug"); + + private static final DockerImageName FIREFOX_DEBUG_IMAGE = DockerImageName.parse( + "selenium/standalone-firefox-debug" + ); + + private static final DockerImageName[] COMPATIBLE_IMAGES = new DockerImageName[] { + CHROME_IMAGE, + FIREFOX_IMAGE, + EDGE_IMAGE, + CHROME_DEBUG_IMAGE, + FIREFOX_DEBUG_IMAGE, + }; + + private static final String DEFAULT_PASSWORD = "secret"; + + private static final int SELENIUM_PORT = 4444; + + private static final int VNC_PORT = 5900; + + private static final String NO_PROXY_KEY = "no_proxy"; + + private static final String TC_TEMP_DIR_PREFIX = "tc"; + + private VncRecordingMode recordingMode = VncRecordingMode.RECORD_FAILING; + + private VncRecordingFormat recordingFormat; + + private RecordingFileFactory recordingFileFactory; + + private File vncRecordingDirectory; + + private VncRecordingContainer vncRecordingContainer = null; + + private static final Logger LOGGER = LoggerFactory.getLogger(BrowserWebDriverContainer.class); + + /** + * Constructor taking a specific webdriver container name and tag + * @param dockerImageName Name of the selenium docker image + */ + public BrowserWebDriverContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + /** + * Constructor taking a specific webdriver container name and tag + * @param dockerImageName Name of the selenium docker image + */ + public BrowserWebDriverContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(COMPATIBLE_IMAGES); + + waitingFor(getDefaultWaitStrategy()); + + withRecordingFileFactory(new DefaultRecordingFileFactory()); + // We have to force SKIP mode for the recording by default because we don't know if the image has VNC or not + recordingMode = VncRecordingMode.SKIP; + } + + @NotNull + @Override + protected Set getLivenessCheckPorts() { + Integer seleniumPort = getMappedPort(SELENIUM_PORT); + if (recordingMode == VncRecordingMode.SKIP) { + return ImmutableSet.of(seleniumPort); + } else { + return ImmutableSet.of(seleniumPort, getMappedPort(VNC_PORT)); + } + } + + @Override + protected void configure() { + if (recordingMode != VncRecordingMode.SKIP) { + if (vncRecordingDirectory == null) { + try { + vncRecordingDirectory = Files.createTempDirectory(TC_TEMP_DIR_PREFIX).toFile(); + } catch (IOException e) { + // should never happen as per javadoc, since we use valid prefix + logger().error("Exception while trying to create temp directory", e); + throw new ContainerLaunchException("Exception while trying to create temp directory", e); + } + } + + if (getNetwork() == null) { + withNetwork(Network.SHARED); + } + + vncRecordingContainer = + new VncRecordingContainer(this) + .withVncPassword(DEFAULT_PASSWORD) + .withVncPort(VNC_PORT) + .withVideoFormat(recordingFormat); + } + + String timeZone = System.getProperty("user.timezone"); + + if (timeZone == null || timeZone.isEmpty()) { + timeZone = "Etc/UTC"; + } + + addExposedPorts(SELENIUM_PORT, VNC_PORT); + addEnv("TZ", timeZone); + + if (!getEnvMap().containsKey(NO_PROXY_KEY)) { + addEnv(NO_PROXY_KEY, "localhost"); + } + + setCommand("/opt/bin/entry_point.sh"); + + if (getShmSize() == null) { + if (SystemUtils.IS_OS_WINDOWS) { + withSharedMemorySize(512 * FileUtils.ONE_MB); + } else { + this.getBinds().add(new Bind("/dev/shm", new Volume("/dev/shm"), AccessMode.rw)); + } + } + + /* + * Some unreliability of the selenium browser containers has been observed, so allow multiple attempts to start. + */ + setStartupAttempts(3); + } + + public URL getSeleniumAddress() { + try { + return new URL("http", getHost(), getMappedPort(SELENIUM_PORT), "/wd/hub"); + } catch (MalformedURLException e) { + e.printStackTrace(); // TODO + return null; + } + } + + public String getVncAddress() { + return "vnc://vnc:secret@" + getHost() + ":" + getMappedPort(VNC_PORT); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + if (vncRecordingContainer != null) { + LOGGER.debug("Starting VNC recording"); + vncRecordingContainer.start(); + } + } + + @Override + public void afterTest(TestDescription description, Optional throwable) { + retainRecordingIfNeeded(description.getFilesystemFriendlyName(), !throwable.isPresent()); + } + + @Override + public void stop() { + if (vncRecordingContainer != null) { + try { + vncRecordingContainer.stop(); + } catch (Exception e) { + LOGGER.debug("Failed to stop vncRecordingContainer", e); + } + vncRecordingContainer = null; + } + + super.stop(); + } + + private void retainRecordingIfNeeded(String prefix, boolean succeeded) { + final boolean shouldRecord; + switch (recordingMode) { + case RECORD_ALL: + shouldRecord = true; + break; + case RECORD_FAILING: + shouldRecord = !succeeded; + break; + default: + shouldRecord = false; + break; + } + + if (shouldRecord) { + File recordingFile = recordingFileFactory.recordingFileForTest( + vncRecordingDirectory, + prefix, + succeeded, + vncRecordingContainer.getVideoFormat() + ); + LOGGER.info("Screen recordings for test {} will be stored at: {}", prefix, recordingFile); + + vncRecordingContainer.saveRecordingToFile(recordingFile); + } + } + + public BrowserWebDriverContainer withRecordingMode(VncRecordingMode recordingMode, File vncRecordingDirectory) { + return withRecordingMode(recordingMode, vncRecordingDirectory, null); + } + + public BrowserWebDriverContainer withRecordingMode( + VncRecordingMode recordingMode, + File vncRecordingDirectory, + VncRecordingFormat recordingFormat + ) { + this.recordingMode = recordingMode; + this.vncRecordingDirectory = vncRecordingDirectory; + this.recordingFormat = recordingFormat; + return self(); + } + + public BrowserWebDriverContainer withRecordingFileFactory(RecordingFileFactory recordingFileFactory) { + this.recordingFileFactory = recordingFileFactory; + return self(); + } + + private WaitStrategy getDefaultWaitStrategy() { + final WaitStrategy logWaitStrategy = new LogMessageWaitStrategy() + .withRegEx( + ".*(RemoteWebDriver instances should connect to|Selenium Server is up and running|Started Selenium Standalone).*\n" + ) + .withStartupTimeout(Duration.ofMinutes(1)); + + return new WaitAllStrategy() + .withStrategy(logWaitStrategy) + .withStrategy(new HostPortWaitStrategy()) + .withStartupTimeout(Duration.ofMinutes(1)); + } + + public enum VncRecordingMode { + SKIP, + RECORD_ALL, + RECORD_FAILING, + } +} diff --git a/modules/selenium/src/test/java/org/testcontainers/containers/DefaultRecordingFileFactoryTest.java b/modules/selenium/src/test/java/org/testcontainers/containers/DefaultRecordingFileFactoryTest.java new file mode 100644 index 00000000000..03540e54b01 --- /dev/null +++ b/modules/selenium/src/test/java/org/testcontainers/containers/DefaultRecordingFileFactoryTest.java @@ -0,0 +1,69 @@ +package org.testcontainers.containers; + +import lombok.Value; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.File; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@ParameterizedClass +@MethodSource("data") +@Value +class DefaultRecordingFileFactoryTest { + + private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("YYYYMMdd-HHmmss"); + + private final DefaultRecordingFileFactory factory = new DefaultRecordingFileFactory(); + + private final String methodName; + + private final String prefix; + + private final boolean success; + + public static Collection data() { + Collection args = new ArrayList<>(); + args.add(new Object[] { "testMethod1", "FAILED", Boolean.FALSE }); + args.add(new Object[] { "testMethod2", "PASSED", Boolean.TRUE }); + return args; + } + + @Test + public void recordingFileThatShouldDescribeTheTestResultAtThePresentTime(TestInfo testInfo) throws Exception { + File vncRecordingDirectory = Files.createTempDirectory("recording").toFile(); + String className = testInfo.getTestClass().orElseThrow(IllegalStateException::new).getSimpleName(); + String description = className + "-" + methodName; + + File recordingFile = factory.recordingFileForTest(vncRecordingDirectory, description, success); + + String expectedFilePrefix = String.format("%s-%s-%s", prefix, getClass().getSimpleName(), methodName); + + List expectedPossibleFileNames = Arrays.asList( + new File( + vncRecordingDirectory, + String.format("%s-%s.flv", expectedFilePrefix, LocalDateTime.now().format(DATETIME_FORMATTER)) + ), + new File( + vncRecordingDirectory, + String.format( + "%s-%s.flv", + expectedFilePrefix, + LocalDateTime.now().minusSeconds(1L).format(DATETIME_FORMATTER) + ) + ) + ); + + assertThat(expectedPossibleFileNames).contains(recordingFile); + } +} diff --git a/modules/selenium/src/test/java/org/testcontainers/junit/BaseWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/junit/BaseWebDriverContainerTest.java deleted file mode 100644 index d9c76626a9e..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/BaseWebDriverContainerTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.testcontainers.junit; - -import org.jetbrains.annotations.NotNull; -import org.openqa.selenium.By; -import org.openqa.selenium.remote.RemoteWebDriver; -import org.testcontainers.containers.BrowserWebDriverContainer; - -import java.util.concurrent.TimeUnit; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; - -/** - * - */ -public class BaseWebDriverContainerTest { - - protected void doSimpleWebdriverTest(BrowserWebDriverContainer rule) { - RemoteWebDriver driver = setupDriverFromRule(rule); - System.out.println("Selenium remote URL is: " + rule.getSeleniumAddress()); - System.out.println("VNC URL is: " + rule.getVncAddress()); - - //Runtime.getRuntime().exec("open " + rule.getVncUrl(driver)); // For debugging, on a Mac - - driver.get("http://www.google.com"); - driver.findElement(By.name("q")).sendKeys("testcontainers"); - driver.findElement(By.name("q")).submit(); - assertEquals("the word 'testcontainers' appears in the search box", "testcontainers", driver.findElement(By.name("q")).getAttribute("value")); - } - - @NotNull - private RemoteWebDriver setupDriverFromRule(BrowserWebDriverContainer rule) { - RemoteWebDriver driver = rule.getWebDriver(); - driver.manage().timeouts().implicitlyWait(30, TimeUnit.SECONDS); - return driver; - } - - protected void doSimpleExplore(BrowserWebDriverContainer rule) { - RemoteWebDriver driver = setupDriverFromRule(rule); - driver.get("http://en.wikipedia.org/wiki/Randomness"); - } - -} diff --git a/modules/selenium/src/test/java/org/testcontainers/junit/ChromeRecordingWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/junit/ChromeRecordingWebDriverContainerTest.java deleted file mode 100644 index 5698b083339..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/ChromeRecordingWebDriverContainerTest.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.testcontainers.junit; - -import org.junit.Rule; -import org.junit.Test; -import org.openqa.selenium.remote.DesiredCapabilities; -import org.testcontainers.containers.BrowserWebDriverContainer; - -import java.io.File; - -import static org.testcontainers.containers.BrowserWebDriverContainer.VncRecordingMode.RECORD_ALL; - -/** - * - */ -public class ChromeRecordingWebDriverContainerTest extends BaseWebDriverContainerTest { - - @Rule - public BrowserWebDriverContainer chromeThatRecordsAllTests = new BrowserWebDriverContainer() - .withDesiredCapabilities(DesiredCapabilities.chrome()) - .withRecordingMode(RECORD_ALL, new File("./target/")); - - @Rule - public BrowserWebDriverContainer chromeThatRecordsFailingTests = new BrowserWebDriverContainer() - .withDesiredCapabilities(DesiredCapabilities.chrome()); - - - @Test - public void recordingTestThatShouldBeRecordedButDeleted() { - doSimpleExplore(chromeThatRecordsFailingTests); - } - - @Test - public void recordingTestThatShouldBeRecordedAndRetained() { - doSimpleExplore(chromeThatRecordsAllTests); - } -} diff --git a/modules/selenium/src/test/java/org/testcontainers/junit/ChromeWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/junit/ChromeWebDriverContainerTest.java deleted file mode 100644 index 66468baf092..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/ChromeWebDriverContainerTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.testcontainers.junit; - -import org.junit.Rule; -import org.junit.Test; -import org.openqa.selenium.remote.DesiredCapabilities; -import org.testcontainers.containers.BrowserWebDriverContainer; - -import java.io.IOException; - -/** - * - */ -public class ChromeWebDriverContainerTest extends BaseWebDriverContainerTest { - - @Rule - public BrowserWebDriverContainer chrome = new BrowserWebDriverContainer() - .withDesiredCapabilities(DesiredCapabilities.chrome()); - - @Test - public void simpleTest() throws IOException { - doSimpleWebdriverTest(chrome); - } - - @Test - public void simpleExploreTest() throws IOException { - doSimpleExplore(chrome); - } -} diff --git a/modules/selenium/src/test/java/org/testcontainers/junit/FirefoxWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/junit/FirefoxWebDriverContainerTest.java deleted file mode 100644 index 7bd7e3b9ad1..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/FirefoxWebDriverContainerTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.testcontainers.junit; - -import org.junit.Rule; -import org.junit.Test; -import org.openqa.selenium.remote.DesiredCapabilities; -import org.testcontainers.containers.BrowserWebDriverContainer; - -import java.io.IOException; - -/** - * - */ -public class FirefoxWebDriverContainerTest extends BaseWebDriverContainerTest { - - @Rule - public BrowserWebDriverContainer firefox = new BrowserWebDriverContainer() - .withDesiredCapabilities(DesiredCapabilities.firefox()); - - @Test - public void simpleTest() throws IOException { - doSimpleWebdriverTest(firefox); - } - - @Test - public void simpleExploreTest() throws IOException { - doSimpleExplore(firefox); - } -} diff --git a/modules/selenium/src/test/java/org/testcontainers/junit/FlakyContainerCreationTest.java b/modules/selenium/src/test/java/org/testcontainers/junit/FlakyContainerCreationTest.java deleted file mode 100644 index 56eea12a325..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/FlakyContainerCreationTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.testcontainers.junit; - -import org.junit.Ignore; -import org.junit.Test; -import org.openqa.selenium.remote.DesiredCapabilities; -import org.openqa.selenium.remote.RemoteWebDriver; -import org.testcontainers.containers.BrowserWebDriverContainer; - -import java.io.File; - -/** - * Created by rnorth on 30/01/2016. - */ -public class FlakyContainerCreationTest { - - @Test @Ignore - public void testCreationOfManyContainers() { - for (int i = 0; i < 50; i++) { - BrowserWebDriverContainer container = new BrowserWebDriverContainer() - .withDesiredCapabilities(DesiredCapabilities.chrome()) - .withRecordingMode(BrowserWebDriverContainer.VncRecordingMode.RECORD_FAILING, new File("target")); - - container.start(); - RemoteWebDriver driver = container.getWebDriver(); - - driver.get("http://www.google.com"); - - container.stop(); - } - } -} diff --git a/modules/selenium/src/test/java/org/testcontainers/junit/LocalServerWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/junit/LocalServerWebDriverContainerTest.java deleted file mode 100644 index 255d1eb69f7..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/LocalServerWebDriverContainerTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package org.testcontainers.junit; - -import org.junit.*; -import org.mortbay.jetty.Server; -import org.mortbay.jetty.bio.SocketConnector; -import org.mortbay.jetty.handler.ResourceHandler; -import org.openqa.selenium.By; -import org.openqa.selenium.remote.DesiredCapabilities; -import org.openqa.selenium.remote.RemoteWebDriver; -import org.testcontainers.containers.BrowserWebDriverContainer; -import org.testcontainers.utility.TestEnvironment; - -import static org.apache.commons.lang.SystemUtils.IS_OS_MAC_OSX; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; - -/** - * Test that a browser running in a container can access a web server hosted on the host machine (i.e. the one running - * the tests) - */ -public class LocalServerWebDriverContainerTest { - - @Rule - public BrowserWebDriverContainer chrome = new BrowserWebDriverContainer().withDesiredCapabilities(DesiredCapabilities.chrome()); - private int localPort; - - /** - * The getTestHostIpAddress() method is only implemented for OS X running docker-machine. Skip JUnit execution elsewhere. - */ - @BeforeClass - public static void checkOS() { - Assume.assumeTrue("These tests are currently only applicable to OS X", IS_OS_MAC_OSX); - Assume.assumeTrue("These tests are only applicable to docker machine", TestEnvironment.dockerIsDockerMachine()); - } - - @Before - public void setupLocalServer() throws Exception { - - // Set up a local Jetty HTTP server - Server server = new Server(); - server.addConnector(new SocketConnector()); - ResourceHandler resourceHandler = new ResourceHandler(); - resourceHandler.setResourceBase("src/test/resources/server"); - server.addHandler(resourceHandler); - server.start(); - - // The server will have a random port assigned, so capture that - localPort = server.getConnectors()[0].getLocalPort(); - } - - @Test - public void testConnection() throws InterruptedException { - RemoteWebDriver driver = chrome.getWebDriver(); - - // Construct a URL that the browser container can access - String hostIpAddress = chrome.getTestHostIpAddress(); - driver.get("http://" + hostIpAddress + ":" + localPort); - - String headingText = driver.findElement(By.cssSelector("h1")).getText().trim(); - - assertEquals("The hardcoded success message was found on a page fetched from a local server", "It worked", headingText); - } -} diff --git a/modules/selenium/src/test/java/org/testcontainers/junit/SeleniumStartTest.java b/modules/selenium/src/test/java/org/testcontainers/junit/SeleniumStartTest.java new file mode 100644 index 00000000000..a1923b0264e --- /dev/null +++ b/modules/selenium/src/test/java/org/testcontainers/junit/SeleniumStartTest.java @@ -0,0 +1,35 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.MethodSource; +import org.openqa.selenium.chrome.ChromeOptions; +import org.testcontainers.containers.BrowserWebDriverContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Simple test to check that readiness detection works correctly across major versions of the containers. + */ +@ParameterizedClass(name = "tag: {0}") +@MethodSource("data") +public class SeleniumStartTest { + + public static String[] data() { + return new String[] { "4.0.0", "3.4.0", "2.53.0" }; + } + + @Parameter + public String tag; + + @Test + void testAdditionalStartupString() { + final DockerImageName imageName = DockerImageName.parse("selenium/standalone-chrome").withTag(tag); + try ( + BrowserWebDriverContainer chrome = new BrowserWebDriverContainer<>(imageName) + .withCapabilities(new ChromeOptions()) + ) { + chrome.start(); + } + } +} diff --git a/modules/selenium/src/test/java/org/testcontainers/junit/SeleniumUtilsTest.java b/modules/selenium/src/test/java/org/testcontainers/junit/SeleniumUtilsTest.java new file mode 100644 index 00000000000..b9c2125f93b --- /dev/null +++ b/modules/selenium/src/test/java/org/testcontainers/junit/SeleniumUtilsTest.java @@ -0,0 +1,39 @@ +package org.testcontainers.junit; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.SeleniumUtils; + +import java.io.IOException; +import java.util.jar.Manifest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Created by Julien LAMY + */ +class SeleniumUtilsTest { + + @Test + void detectSeleniumVersionUnder3() throws IOException { + checkSeleniumVersionDetected("manifests/MANIFEST-2.45.0.MF", "2.45.0"); + } + + @Test + void detectSeleniumVersionUpper3() throws IOException { + checkSeleniumVersionDetected("manifests/MANIFEST-3.5.2.MF", "3.5.2"); + } + + /** + * Check if Selenium Version detected is the correct one. + * @param urlManifest : manifest file + * @throws IOException + */ + private void checkSeleniumVersionDetected(String urlManifest, String expectedVersion) throws IOException { + Manifest manifest = new Manifest(); + manifest.read(this.getClass().getClassLoader().getResourceAsStream(urlManifest)); + String seleniumVersion = SeleniumUtils.getSeleniumVersionFromManifest(manifest); + assertThat(seleniumVersion) + .as("Check if Selenium Version detected is the correct one.") + .isEqualTo(expectedVersion); + } +} diff --git a/modules/selenium/src/test/java/org/testcontainers/junit/SpecificImageNameWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/junit/SpecificImageNameWebDriverContainerTest.java deleted file mode 100644 index 1db034502ac..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/SpecificImageNameWebDriverContainerTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.testcontainers.junit; - -import org.junit.Rule; -import org.junit.Test; -import org.openqa.selenium.remote.DesiredCapabilities; -import org.testcontainers.containers.BrowserWebDriverContainer; - -import java.io.IOException; - -/** - * - */ -public class SpecificImageNameWebDriverContainerTest extends BaseWebDriverContainerTest { - - @Rule - public BrowserWebDriverContainer firefox = new BrowserWebDriverContainer("selenium/standalone-firefox-debug:2.53.1-beryllium") - .withDesiredCapabilities(DesiredCapabilities.firefox()); - - @Test - public void simpleTest() throws IOException { - doSimpleWebdriverTest(firefox); - } - - @Test - public void simpleExploreTest() throws IOException { - doSimpleExplore(firefox); - } -} diff --git a/modules/selenium/src/test/java/org/testcontainers/selenium/BaseWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/selenium/BaseWebDriverContainerTest.java new file mode 100644 index 00000000000..eb7f946b6fa --- /dev/null +++ b/modules/selenium/src/test/java/org/testcontainers/selenium/BaseWebDriverContainerTest.java @@ -0,0 +1,58 @@ +package org.testcontainers.selenium; + +import org.openqa.selenium.By; +import org.openqa.selenium.Capabilities; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +public class BaseWebDriverContainerTest { + + public static Network NETWORK = Network.newNetwork(); + + public static GenericContainer HELLO_WORLD = new GenericContainer<>( + DockerImageName.parse("testcontainers/helloworld:1.1.0") + ) + .withNetwork(NETWORK) + .withNetworkAliases("helloworld") + .withExposedPorts(8080, 8081) + .waitingFor(new HttpWaitStrategy()); + + static { + HELLO_WORLD.start(); + } + + protected static void doSimpleExplore(BrowserWebDriverContainer rule, Capabilities capabilities) { + RemoteWebDriver driver = new RemoteWebDriver(rule.getSeleniumAddress(), capabilities); + driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(30)); + System.out.println("Selenium remote URL is: " + rule.getSeleniumAddress()); + System.out.println("VNC URL is: " + rule.getVncAddress()); + + driver.get("http://helloworld:8080"); + WebElement title = driver.findElement(By.tagName("h1")); + + assertThat(title.getText().trim()) + .as("the index page contains the title 'Hello world'") + .isEqualTo("Hello world"); + driver.quit(); + } + + protected void assertBrowserNameIs( + BrowserWebDriverContainer container, + String expectedName, + Capabilities capabilities + ) { + RemoteWebDriver driver = new RemoteWebDriver(container.getSeleniumAddress(), capabilities); + driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(30)); + String actual = driver.getCapabilities().getBrowserName(); + assertThat(actual).as(String.format("actual browser name is %s", actual)).isEqualTo(expectedName); + driver.quit(); + } +} diff --git a/modules/selenium/src/test/java/org/testcontainers/selenium/BrowserWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/selenium/BrowserWebDriverContainerTest.java new file mode 100644 index 00000000000..b5e632cd217 --- /dev/null +++ b/modules/selenium/src/test/java/org/testcontainers/selenium/BrowserWebDriverContainerTest.java @@ -0,0 +1,95 @@ +package org.testcontainers.selenium; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.SystemUtils; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +class BrowserWebDriverContainerTest { + + private static final String NO_PROXY_KEY = "no_proxy"; + + private static final String NO_PROXY_VALUE = "localhost,.noproxy-domain.com"; + + @Test + void honorPresetNoProxyEnvironment() { + try ( + BrowserWebDriverContainer chromeWithNoProxySet = new BrowserWebDriverContainer( + "selenium/standalone-chrome:4.13.0" + ) + .withEnv(NO_PROXY_KEY, NO_PROXY_VALUE) + ) { + chromeWithNoProxySet.start(); + + Object noProxy = chromeWithNoProxySet.getEnvMap().get(NO_PROXY_KEY); + assertThat(noProxy).as("no_proxy should be preserved by the container rule").isEqualTo(NO_PROXY_VALUE); + } + } + + @Test + void provideDefaultNoProxyEnvironmentIfNotSet() { + try ( + BrowserWebDriverContainer chromeWithoutNoProxySet = new BrowserWebDriverContainer( + "selenium/standalone-chrome:4.13.0" + ) + ) { + chromeWithoutNoProxySet.start(); + + Object noProxy = chromeWithoutNoProxySet.getEnvMap().get(NO_PROXY_KEY); + assertThat(noProxy).as("no_proxy should be set to default if not already present").isEqualTo("localhost"); + } + } + + @Test + void createContainerWithShmVolume() { + assumeThat(SystemUtils.IS_OS_WINDOWS).isTrue(); + try ( + BrowserWebDriverContainer webDriverContainer = new BrowserWebDriverContainer( + "selenium/standalone-firefox:4.13.0" + ) + ) { + webDriverContainer.start(); + + final List shmVolumes = shmVolumes(webDriverContainer); + + assertThat(shmVolumes).as("Only one shm mount present").hasSize(1); + assertThat(shmVolumes.get(0).getSource()).as("Shm mount source is correct").isEqualTo("/dev/shm"); + assertThat(shmVolumes.get(0).getMode()).as("Shm mount mode is correct").isEqualTo("rw"); + } + } + + @Test + void createContainerWithoutShmVolume() { + try ( + BrowserWebDriverContainer webDriverContainer = new BrowserWebDriverContainer( + "selenium/standalone-firefox:4.13.0" + ) + .withSharedMemorySize(512 * FileUtils.ONE_MB) + ) { + webDriverContainer.start(); + + assertThat(webDriverContainer.getShmSize()) + .as("Shared memory size is configured") + .isEqualTo(512 * FileUtils.ONE_MB); + + assertThat(shmVolumes(webDriverContainer)).as("No shm mounts present").isEqualTo(Collections.emptyList()); + } + } + + private List shmVolumes(final BrowserWebDriverContainer container) { + return container + .getContainerInfo() + .getMounts() + .stream() + // destination path is always /dev/shm + .filter(m -> m.getDestination().getPath().equals("/dev/shm")) + .collect(Collectors.toList()); + } +} diff --git a/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java new file mode 100644 index 00000000000..dbb39b86599 --- /dev/null +++ b/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java @@ -0,0 +1,217 @@ +package org.testcontainers.selenium; + +import com.google.common.io.PatternFilenameFilter; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openqa.selenium.chrome.ChromeOptions; +import org.testcontainers.containers.DefaultRecordingFileFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.VncRecordingContainer; +import org.testcontainers.containers.VncRecordingContainer.VncRecordingFormat; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +import org.testcontainers.lifecycle.TestDescription; +import org.testcontainers.selenium.BrowserWebDriverContainer.VncRecordingMode; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class ChromeRecordingWebDriverContainerTest extends BaseWebDriverContainerTest { + + /** + * Guaranty a minimum video length for FFmpeg re-encoding. + * @see VncRecordingFormat#reencodeRecording(VncRecordingContainer, String) + */ + private static final int MINIMUM_VIDEO_DURATION_MILLISECONDS = 200; + + @Nested + class ChromeThatRecordsAllTests { + + @TempDir + public Path vncRecordingDirectory; + + @Test + void recordingTestThatShouldBeRecordedAndRetainedInFlvFormatAsDefault() throws InterruptedException { + File target = vncRecordingDirectory.toFile(); + try ( + // recordAll { + // To do this, simply add extra parameters to the rule constructor, so video will default to FLV format: + BrowserWebDriverContainer chrome = new BrowserWebDriverContainer("selenium/standalone-chrome:4.13.0") + .withRecordingMode(VncRecordingMode.RECORD_ALL, target) + // } + .withRecordingFileFactory(new DefaultRecordingFileFactory()) + .withNetwork(NETWORK) + ) { + File[] files = runSimpleExploreInContainer(chrome, "PASSED-.*\\.flv"); + assertThat(files).as("Recorded file found").hasSize(1); + } + } + + private File[] runSimpleExploreInContainer(BrowserWebDriverContainer container, String fileNamePattern) + throws InterruptedException { + container.start(); + + TimeUnit.MILLISECONDS.sleep(MINIMUM_VIDEO_DURATION_MILLISECONDS); + doSimpleExplore(container, new ChromeOptions()); + container.afterTest( + new TestDescription() { + @Override + public String getTestId() { + return getFilesystemFriendlyName(); + } + + @Override + public String getFilesystemFriendlyName() { + return "ChromeThatRecordsAllTests-recordingTestThatShouldBeRecordedAndRetained"; + } + }, + Optional.empty() + ); + + return vncRecordingDirectory.toFile().listFiles(new PatternFilenameFilter(fileNamePattern)); + } + + @Test + void recordingTestShouldHaveFlvExtension() throws InterruptedException { + File target = vncRecordingDirectory.toFile(); + try ( + // recordFlv { + // Set (explicitly) FLV format for recorded video: + BrowserWebDriverContainer chrome = new BrowserWebDriverContainer("selenium/standalone-chrome:4.13.0") + .withRecordingMode(VncRecordingMode.RECORD_ALL, target, VncRecordingFormat.FLV) + // } + .withRecordingFileFactory(new DefaultRecordingFileFactory()) + .withNetwork(NETWORK) + ) { + File[] files = runSimpleExploreInContainer(chrome, "PASSED-.*\\.flv"); + assertThat(files).as("Recorded file found").hasSize(1); + } + } + + @Test + void recordingTestShouldHaveMp4Extension() throws InterruptedException { + File target = vncRecordingDirectory.toFile(); + try ( + // recordMp4 { + // Set MP4 format for recorded video: + BrowserWebDriverContainer chrome = new BrowserWebDriverContainer("selenium/standalone-chrome:4.13.0") + .withRecordingMode(VncRecordingMode.RECORD_ALL, target, VncRecordingFormat.MP4) + // } + .withRecordingFileFactory(new DefaultRecordingFileFactory()) + .withNetwork(NETWORK) + ) { + File[] files = runSimpleExploreInContainer(chrome, "PASSED-.*\\.mp4"); + assertThat(files).as("Recorded file found").hasSize(1); + } + } + + @Test + void recordingTestThatShouldHaveCorrectDuration() throws IOException, InterruptedException { + MountableFile mountableFile; + try ( + BrowserWebDriverContainer chrome = new BrowserWebDriverContainer("selenium/standalone-chrome:4.13.0") + .withRecordingMode(VncRecordingMode.RECORD_ALL, vncRecordingDirectory.toFile()) + .withRecordingFileFactory(new DefaultRecordingFileFactory()) + .withNetwork(NETWORK) + ) { + File[] recordedFiles = runSimpleExploreInContainer(chrome, "PASSED-.*\\.flv"); + mountableFile = MountableFile.forHostPath(recordedFiles[0].getCanonicalPath()); + } + + try ( + GenericContainer container = new GenericContainer<>( + DockerImageName.parse("testcontainers/vnc-recorder:1.3.0") + ) + ) { + String recordFileContainerPath = "/tmp/chromeTestRecord.flv"; + container + .withCopyFileToContainer(mountableFile, recordFileContainerPath) + .withCreateContainerCmdModifier(createContainerCmd -> createContainerCmd.withEntrypoint("ffmpeg")) + .withCommand("-i", recordFileContainerPath, "-f", "null", "-") + .waitingFor( + new LogMessageWaitStrategy() + .withRegEx(".*Duration.*") + .withStartupTimeout(Duration.of(60, ChronoUnit.SECONDS)) + ) + .start(); + String ffmpegOutput = container.getLogs(); + + assertThat(ffmpegOutput) + .as("Duration starts with 00:") + .contains("Duration: 00:") + .doesNotContain("Duration: 00:00:00.00"); + } + } + } + + @Nested + class ChromeThatRecordsFailingTests { + + @TempDir + public Path vncRecordingDirectory; + + @Test + void recordingTestThatShouldBeRecordedButNotPersisted() { + try ( + // withRecordingFileFactory { + BrowserWebDriverContainer chrome = new BrowserWebDriverContainer("selenium/standalone-chrome:4.13.0") + // } + // withRecordingFileFactory { + .withRecordingFileFactory(new CustomRecordingFileFactory()) + // } + .withNetwork(NETWORK) + ) { + chrome.start(); + + doSimpleExplore(chrome, new ChromeOptions()); + } + } + + @Test + void recordingTestThatShouldBeRecordedAndRetained() throws InterruptedException { + File target = vncRecordingDirectory.toFile(); + try ( + // recordFailing { + // or if you only want videos for test failures: + BrowserWebDriverContainer chrome = new BrowserWebDriverContainer("selenium/standalone-chrome:4.13.0") + .withRecordingMode(VncRecordingMode.RECORD_FAILING, target) + // } + .withRecordingFileFactory(new DefaultRecordingFileFactory()) + .withNetwork(NETWORK) + ) { + chrome.start(); + + TimeUnit.MILLISECONDS.sleep(MINIMUM_VIDEO_DURATION_MILLISECONDS); + doSimpleExplore(chrome, new ChromeOptions()); + chrome.afterTest( + new TestDescription() { + @Override + public String getTestId() { + return getFilesystemFriendlyName(); + } + + @Override + public String getFilesystemFriendlyName() { + return "ChromeThatRecordsFailingTests-recordingTestThatShouldBeRecordedAndRetained"; + } + }, + Optional.of(new RuntimeException("Force writing of video file.")) + ); + + String[] files = vncRecordingDirectory.toFile().list(new PatternFilenameFilter("FAILED-.*\\.flv")); + assertThat(files).as("recorded file count").hasSize(1); + } + } + + class CustomRecordingFileFactory extends DefaultRecordingFileFactory {} + } +} diff --git a/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeWebDriverContainerTest.java new file mode 100644 index 00000000000..85f0378f84a --- /dev/null +++ b/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeWebDriverContainerTest.java @@ -0,0 +1,24 @@ +package org.testcontainers.selenium; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.chrome.ChromeOptions; + +class ChromeWebDriverContainerTest extends BaseWebDriverContainerTest { + + // junitRule { + public BrowserWebDriverContainer chrome = new BrowserWebDriverContainer("selenium/standalone-chrome:4.13.0") + // } + .withNetwork(NETWORK); + + @BeforeEach + public void checkBrowserIsIndeedChrome() { + chrome.start(); + assertBrowserNameIs(chrome, "chrome", new ChromeOptions()); + } + + @Test + void simpleExploreTest() { + doSimpleExplore(chrome, new ChromeOptions()); + } +} diff --git a/modules/selenium/src/test/java/org/testcontainers/selenium/ContainerWithoutCapabilitiesTest.java b/modules/selenium/src/test/java/org/testcontainers/selenium/ContainerWithoutCapabilitiesTest.java new file mode 100644 index 00000000000..c706a0ce0ef --- /dev/null +++ b/modules/selenium/src/test/java/org/testcontainers/selenium/ContainerWithoutCapabilitiesTest.java @@ -0,0 +1,28 @@ +package org.testcontainers.selenium; + +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.chrome.ChromeOptions; + +class ContainerWithoutCapabilitiesTest extends BaseWebDriverContainerTest { + + @AutoClose + public BrowserWebDriverContainer chrome = new BrowserWebDriverContainer("selenium/standalone-chrome:4.13.0") + .withNetwork(NETWORK); + + @BeforeEach + public void setUp() { + chrome.start(); + } + + @Test + void chromeIsStartedIfNoCapabilitiesProvided() { + assertBrowserNameIs(chrome, "chrome", new ChromeOptions()); + } + + @Test + void simpleExploreTestWhenNoCapabilitiesProvided() { + doSimpleExplore(chrome, new ChromeOptions()); + } +} diff --git a/modules/selenium/src/test/java/org/testcontainers/selenium/CustomWaitTimeoutWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/selenium/CustomWaitTimeoutWebDriverContainerTest.java new file mode 100644 index 00000000000..61d14bab65f --- /dev/null +++ b/modules/selenium/src/test/java/org/testcontainers/selenium/CustomWaitTimeoutWebDriverContainerTest.java @@ -0,0 +1,22 @@ +package org.testcontainers.selenium; + +import org.junit.jupiter.api.Test; +import org.openqa.selenium.chrome.ChromeOptions; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +class CustomWaitTimeoutWebDriverContainerTest extends BaseWebDriverContainerTest { + + public BrowserWebDriverContainer chromeWithCustomTimeout = new BrowserWebDriverContainer( + "selenium/standalone-chrome:4.13.0" + ) + .withStartupTimeout(Duration.of(30, ChronoUnit.SECONDS)) + .withNetwork(NETWORK); + + @Test + void simpleExploreTest() { + chromeWithCustomTimeout.start(); + doSimpleExplore(chromeWithCustomTimeout, new ChromeOptions()); + } +} diff --git a/modules/selenium/src/test/java/org/testcontainers/selenium/EdgeWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/selenium/EdgeWebDriverContainerTest.java new file mode 100644 index 00000000000..d99df91f8e1 --- /dev/null +++ b/modules/selenium/src/test/java/org/testcontainers/selenium/EdgeWebDriverContainerTest.java @@ -0,0 +1,24 @@ +package org.testcontainers.selenium; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.edge.EdgeOptions; + +class EdgeWebDriverContainerTest extends BaseWebDriverContainerTest { + + // junitRule { + public BrowserWebDriverContainer edge = new BrowserWebDriverContainer("selenium/standalone-edge:4.13.0") + // } + .withNetwork(NETWORK); + + @BeforeEach + public void checkBrowserIsIndeedMSEdge() { + edge.start(); + assertBrowserNameIs(edge, "msedge", new EdgeOptions()); + } + + @Test + void simpleExploreTest() { + doSimpleExplore(edge, new EdgeOptions()); + } +} diff --git a/modules/selenium/src/test/java/org/testcontainers/selenium/FirefoxWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/selenium/FirefoxWebDriverContainerTest.java new file mode 100644 index 00000000000..50545168260 --- /dev/null +++ b/modules/selenium/src/test/java/org/testcontainers/selenium/FirefoxWebDriverContainerTest.java @@ -0,0 +1,24 @@ +package org.testcontainers.selenium; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.firefox.FirefoxOptions; + +class FirefoxWebDriverContainerTest extends BaseWebDriverContainerTest { + + // junitRule { + public BrowserWebDriverContainer firefox = new BrowserWebDriverContainer("selenium/standalone-firefox:4.13.0") + // } + .withNetwork(NETWORK); + + @BeforeEach + public void checkBrowserIsIndeedFirefox() { + firefox.start(); + assertBrowserNameIs(firefox, "firefox", new FirefoxOptions()); + } + + @Test + void simpleExploreTest() { + doSimpleExplore(firefox, new FirefoxOptions()); + } +} diff --git a/modules/selenium/src/test/java/org/testcontainers/selenium/LocalServerWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/selenium/LocalServerWebDriverContainerTest.java new file mode 100644 index 00000000000..e5819b11786 --- /dev/null +++ b/modules/selenium/src/test/java/org/testcontainers/selenium/LocalServerWebDriverContainerTest.java @@ -0,0 +1,59 @@ +package org.testcontainers.selenium; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mortbay.jetty.Server; +import org.mortbay.jetty.bio.SocketConnector; +import org.mortbay.jetty.handler.ResourceHandler; +import org.openqa.selenium.By; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.testcontainers.Testcontainers; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test that a browser running in a container can access a web server hosted on the host machine (i.e. the one running + * the tests) + */ +class LocalServerWebDriverContainerTest { + + public BrowserWebDriverContainer chrome = new BrowserWebDriverContainer("selenium/standalone-chrome:4.13.0") + .withAccessToHost(true); + + private int localPort; + + @BeforeEach + public void setupLocalServer() throws Exception { + chrome.start(); + // Set up a local Jetty HTTP server + Server server = new Server(); + server.addConnector(new SocketConnector()); + ResourceHandler resourceHandler = new ResourceHandler(); + resourceHandler.setResourceBase("src/test/resources/server"); + server.addHandler(resourceHandler); + server.start(); + + // The server will have a random port assigned, so capture that + localPort = server.getConnectors()[0].getLocalPort(); + } + + @Test + void testConnection() { + // getWebDriver { + RemoteWebDriver driver = new RemoteWebDriver(chrome.getSeleniumAddress(), new ChromeOptions()); + // } + + // Construct a URL that the browser container can access + // getPage { + Testcontainers.exposeHostPorts(localPort); + driver.get("http://host.testcontainers.internal:" + localPort); + // } + + String headingText = driver.findElement(By.cssSelector("h1")).getText().trim(); + + assertThat(headingText) + .as("The hardcoded success message was found on a page fetched from a local server") + .isEqualTo("It worked"); + } +} diff --git a/modules/selenium/src/test/java/org/testcontainers/selenium/SpecificImageNameWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/selenium/SpecificImageNameWebDriverContainerTest.java new file mode 100644 index 00000000000..c01dc5ae97f --- /dev/null +++ b/modules/selenium/src/test/java/org/testcontainers/selenium/SpecificImageNameWebDriverContainerTest.java @@ -0,0 +1,18 @@ +package org.testcontainers.selenium; + +import org.junit.jupiter.api.Test; +import org.openqa.selenium.firefox.FirefoxOptions; +import org.testcontainers.utility.DockerImageName; + +class SpecificImageNameWebDriverContainerTest extends BaseWebDriverContainerTest { + + private static final DockerImageName FIREFOX_IMAGE = DockerImageName.parse("selenium/standalone-firefox:4.10.0"); + + public BrowserWebDriverContainer firefox = new BrowserWebDriverContainer(FIREFOX_IMAGE).withNetwork(NETWORK); + + @Test + void simpleExploreTest() { + firefox.start(); + doSimpleExplore(firefox, new FirefoxOptions()); + } +} diff --git a/modules/selenium/src/test/resources/logback-test.xml b/modules/selenium/src/test/resources/logback-test.xml index ed0e5b2659e..83ef7a1a3ef 100644 --- a/modules/selenium/src/test/resources/logback-test.xml +++ b/modules/selenium/src/test/resources/logback-test.xml @@ -8,22 +8,9 @@ - + - - - - - - - - - - - - PROFILER - DENY - - \ No newline at end of file + + diff --git a/modules/selenium/src/test/resources/manifests/MANIFEST-2.45.0.MF b/modules/selenium/src/test/resources/manifests/MANIFEST-2.45.0.MF new file mode 100644 index 00000000000..ad494a3ec0e --- /dev/null +++ b/modules/selenium/src/test/resources/manifests/MANIFEST-2.45.0.MF @@ -0,0 +1,11 @@ +Manifest-Version: 1.0 +Built-By: linman +Build-Jdk: 1.7.0_65 +Created-By: Apache Maven 3.1.1 +Archiver-Version: Plexus Archiver + +Name: Build-Info +Selenium-Revision: 5017cb8e7ca8e37638dc3091b2440b90a1d8686f +Selenium-Version: 2.45.0 +Selenium-Build-Time: 2015-02-27 09:10:26 + diff --git a/modules/selenium/src/test/resources/manifests/MANIFEST-3.5.2.MF b/modules/selenium/src/test/resources/manifests/MANIFEST-3.5.2.MF new file mode 100755 index 00000000000..772380fed70 --- /dev/null +++ b/modules/selenium/src/test/resources/manifests/MANIFEST-3.5.2.MF @@ -0,0 +1,8 @@ +Manifest-Version: 1.0 + +Name: Build-Info +Build-Revision: 21ac65f960 +Build-Time: 2017-08-21T20:29:34.71Z +Build-User: lamaille +Selenium-Version: 3.5.2 + diff --git a/modules/solace/build.gradle b/modules/solace/build.gradle new file mode 100644 index 00000000000..1a6d2ed5a65 --- /dev/null +++ b/modules/solace/build.gradle @@ -0,0 +1,12 @@ +description = "Testcontainers :: Solace" + +dependencies { + api project(':testcontainers') + + shaded 'org.awaitility:awaitility:4.3.0' + + testImplementation 'com.solacesystems:sol-jcsmp:10.30.1' + testImplementation 'org.apache.qpid:qpid-jms-client:0.61.0' + testImplementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5' + testImplementation 'org.apache.httpcomponents:fluent-hc:4.5.14' +} diff --git a/modules/solace/src/main/java/org/testcontainers/solace/Service.java b/modules/solace/src/main/java/org/testcontainers/solace/Service.java new file mode 100644 index 00000000000..9c9342ef789 --- /dev/null +++ b/modules/solace/src/main/java/org/testcontainers/solace/Service.java @@ -0,0 +1,70 @@ +package org.testcontainers.solace; + +/** + * Services that are supported by Testcontainers implementation + */ +public enum Service { + /** + * Advanced Message Queuing Protocol + */ + AMQP("amqp", 5672, "amqp", false), + /** + * Message Queuing Telemetry Transport + */ + MQTT("mqtt", 1883, "tcp", false), + /** + * Representational State Transfer + */ + REST("rest", 9000, "http", false), + /** + * Solace Message Format + */ + SMF("smf", 55555, "tcp", true), + /** + * Solace Message Format with SSL + */ + SMF_SSL("smf", 55443, "tcps", true); + + private final String name; + + private final Integer port; + + private final String protocol; + + private final boolean supportSSL; + + Service(String name, Integer port, String protocol, boolean supportSSL) { + this.name = name; + this.port = port; + this.protocol = protocol; + this.supportSSL = supportSSL; + } + + /** + * @return Port assigned for the service + */ + public Integer getPort() { + return this.port; + } + + /** + * @return Protocol of the service + */ + public String getProtocol() { + return this.protocol; + } + + /** + * @return Name of the service + */ + public String getName() { + return this.name; + } + + /** + * @return Is SSL for this service supported ? + */ + public boolean isSupportSSL() { + return this.supportSSL; + } +} diff --git a/modules/solace/src/main/java/org/testcontainers/solace/SolaceContainer.java b/modules/solace/src/main/java/org/testcontainers/solace/SolaceContainer.java new file mode 100644 index 00000000000..2ab22c8ffa6 --- /dev/null +++ b/modules/solace/src/main/java/org/testcontainers/solace/SolaceContainer.java @@ -0,0 +1,326 @@ +package org.testcontainers.solace; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.Ulimit; +import org.apache.commons.lang3.tuple.Pair; +import org.awaitility.Awaitility; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * Testcontainers implementation of Solace PubSub+. + *

+ * Supported image: {@code solace/solace-pubsub-standard} + *

+ * Exposed ports: + *

    + *
  • Console: 8080
  • + *
  • AMQP: 5672
  • + *
  • MQTT: 1883
  • + *
  • HTTP: 9000
  • + *
  • SMF: 55555
  • + *
  • SMF SSL: 55443
  • + *
+ */ +public class SolaceContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("solace/solace-pubsub-standard"); + + private static final String DEFAULT_VPN = "default"; + + private static final String DEFAULT_USERNAME = "default"; + + private static final String SOLACE_READY_MESSAGE = ".*Running pre-startup checks:.*"; + + private static final String SOLACE_ACTIVE_MESSAGE = "Primary Virtual Router is now active"; + + private static final String TMP_SCRIPT_LOCATION = "/tmp/script.cli"; + + private static final Long SHM_SIZE = (long) Math.pow(1024, 3); + + private String username = "root"; + + private String password = "password"; + + private String vpn = DEFAULT_VPN; + + private final List> topicsConfiguration = new ArrayList<>(); + + private boolean withClientCert; + + /** + * Create a new solace container with the specified image name. + * + * @param dockerImageName the image name that should be used. + */ + public SolaceContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + /** + * Create a new solace container with the specified docker image. + * + * @param dockerImageName the image name that should be used. + */ + public SolaceContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withCreateContainerCmdModifier(cmd -> { + cmd + .getHostConfig() + .withShmSize(SHM_SIZE) + .withUlimits(new Ulimit[] { new Ulimit("nofile", 2448L, 1048576L) }); + }); + waitingFor(Wait.forLogMessage(SOLACE_READY_MESSAGE, 1).withStartupTimeout(Duration.ofSeconds(60))); + withExposedPorts(8080); + withEnv("username_admin_globalaccesslevel", "admin"); + withEnv("username_admin_password", "admin"); + } + + @Override + protected void configure() { + withCopyToContainer(createConfigurationScript(), TMP_SCRIPT_LOCATION); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + if (withClientCert) { + executeCommand("cp", "/tmp/solace.pem", "/usr/sw/jail/certs/solace.pem"); + executeCommand("cp", "/tmp/rootCA.crt", "/usr/sw/jail/certs/rootCA.crt"); + } + executeCommand("cp", TMP_SCRIPT_LOCATION, "/usr/sw/jail/cliscripts/script.cli"); + waitOnCommandResult(SOLACE_ACTIVE_MESSAGE, "grep", "-R", SOLACE_ACTIVE_MESSAGE, "/usr/sw/jail/logs/system.log"); + executeCommand("/usr/sw/loads/currentload/bin/cli", "-A", "-es", "script.cli"); + } + + private Transferable createConfigurationScript() { + StringBuilder scriptBuilder = new StringBuilder(); + updateConfigScript(scriptBuilder, "enable"); + updateConfigScript(scriptBuilder, "configure"); + + // Create VPN if not default + if (!vpn.equals(DEFAULT_VPN)) { + updateConfigScript(scriptBuilder, "create message-vpn " + vpn); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "exit"); + updateConfigScript(scriptBuilder, "client-profile default message-vpn " + vpn); + updateConfigScript(scriptBuilder, "message-spool"); + updateConfigScript(scriptBuilder, "allow-guaranteed-message-send"); + updateConfigScript(scriptBuilder, "allow-guaranteed-message-receive"); + updateConfigScript(scriptBuilder, "allow-guaranteed-endpoint-create"); + updateConfigScript(scriptBuilder, "allow-guaranteed-endpoint-create-durability all"); + updateConfigScript(scriptBuilder, "exit"); + updateConfigScript(scriptBuilder, "exit"); + updateConfigScript(scriptBuilder, "message-spool message-vpn " + vpn); + updateConfigScript(scriptBuilder, "max-spool-usage 60000"); + updateConfigScript(scriptBuilder, "exit"); + } + + // Configure username and password + if (username.equals(DEFAULT_USERNAME)) { + throw new RuntimeException("Cannot override password for default client"); + } + updateConfigScript(scriptBuilder, "create client-username " + username + " message-vpn " + vpn); + updateConfigScript(scriptBuilder, "password " + password); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "exit"); + + if (withClientCert) { + // Client certificate authority configuration + updateConfigScript(scriptBuilder, "authentication"); + updateConfigScript(scriptBuilder, "create client-certificate-authority RootCA"); + updateConfigScript(scriptBuilder, "certificate file rootCA.crt"); + updateConfigScript(scriptBuilder, "show client-certificate-authority ca-name *"); + updateConfigScript(scriptBuilder, "end"); + + // Server certificates configuration + updateConfigScript(scriptBuilder, "configure"); + updateConfigScript(scriptBuilder, "ssl"); + updateConfigScript(scriptBuilder, "server-certificate solace.pem"); + updateConfigScript(scriptBuilder, "cipher-suite msg-backbone name AES128-SHA"); + updateConfigScript(scriptBuilder, "exit"); + + updateConfigScript(scriptBuilder, "message-vpn " + vpn); + // Enable client certificate authentication + updateConfigScript(scriptBuilder, "authentication client-certificate"); + updateConfigScript(scriptBuilder, "allow-api-provided-username"); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "end"); + } else { + // Configure VPN Basic authentication + updateConfigScript(scriptBuilder, "message-vpn " + vpn); + updateConfigScript(scriptBuilder, "authentication basic auth-type internal"); + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "end"); + } + + if (!topicsConfiguration.isEmpty()) { + // Enable services + updateConfigScript(scriptBuilder, "configure"); + // Configure default ACL + updateConfigScript(scriptBuilder, "acl-profile default message-vpn " + vpn); + // Configure default action to disallow + updateConfigScript(scriptBuilder, "subscribe-topic default-action disallow"); + updateConfigScript(scriptBuilder, "publish-topic default-action disallow"); + updateConfigScript(scriptBuilder, "exit"); + + updateConfigScript(scriptBuilder, "message-vpn " + vpn); + updateConfigScript(scriptBuilder, "service"); + for (Pair topicConfig : topicsConfiguration) { + Service service = topicConfig.getValue(); + String topicName = topicConfig.getKey(); + updateConfigScript(scriptBuilder, service.getName()); + if (service.isSupportSSL()) { + if (withClientCert) { + updateConfigScript(scriptBuilder, "ssl"); + } else { + updateConfigScript(scriptBuilder, "plain-text"); + } + } + updateConfigScript(scriptBuilder, "no shutdown"); + updateConfigScript(scriptBuilder, "end"); + // Add publish/subscribe topic exceptions + updateConfigScript(scriptBuilder, "configure"); + updateConfigScript(scriptBuilder, "acl-profile default message-vpn " + vpn); + updateConfigScript( + scriptBuilder, + String.format("publish-topic exceptions %s list %s", service.getName(), topicName) + ); + updateConfigScript( + scriptBuilder, + String.format("subscribe-topic exceptions %s list %s", service.getName(), topicName) + ); + updateConfigScript(scriptBuilder, "end"); + } + } + return Transferable.of(scriptBuilder.toString()); + } + + private void executeCommand(String... command) { + try { + ExecResult execResult = execInContainer(command); + if (execResult.getExitCode() != 0) { + logCommandError(execResult.getStderr(), command); + } + } catch (IOException | InterruptedException e) { + logCommandError(e.getMessage(), command); + } + } + + private void updateConfigScript(StringBuilder scriptBuilder, String command) { + scriptBuilder.append(command).append("\n"); + } + + private void waitOnCommandResult(String waitingFor, String... command) { + Awaitility + .await() + .pollInterval(Duration.ofMillis(500)) + .timeout(Duration.ofSeconds(30)) + .until(() -> { + try { + return execInContainer(command).getStdout().contains(waitingFor); + } catch (IOException | InterruptedException e) { + logCommandError(e.getMessage(), command); + return true; + } + }); + } + + private void logCommandError(String error, String... command) { + logger().error("Could not execute command {}: {}", command, error); + } + + /** + * Sets the client credentials + * + * @param username Client username + * @param password Client password + * @return This container. + */ + public SolaceContainer withCredentials(final String username, final String password) { + this.username = username; + this.password = password; + return this; + } + + /** + * Adds the topic configuration + * + * @param topic Name of the topic + * @param service Service to be supported on provided topic + * @return This container. + */ + public SolaceContainer withTopic(String topic, Service service) { + topicsConfiguration.add(Pair.of(topic, service)); + addExposedPort(service.getPort()); + return this; + } + + /** + * Sets the VPN name + * + * @param vpn VPN name + * @return This container. + */ + public SolaceContainer withVpn(String vpn) { + this.vpn = vpn; + return this; + } + + /** + * Sets the solace server ceritificates + * + * @param certFile Server certificate + * @param caFile Certified Authority certificate + * @return This container. + */ + public SolaceContainer withClientCert(final MountableFile certFile, final MountableFile caFile) { + this.withClientCert = true; + return withCopyFileToContainer(certFile, "/tmp/solace.pem").withCopyFileToContainer(caFile, "/tmp/rootCA.crt"); + } + + /** + * Configured VPN + * + * @return the configured VPN that should be used for connections + */ + public String getVpn() { + return this.vpn; + } + + /** + * Host address for provided service + * + * @param service - service for which host needs to be retrieved + * @return host address exposed from the container + */ + public String getOrigin(Service service) { + return String.format("%s://%s:%s", service.getProtocol(), getHost(), getMappedPort(service.getPort())); + } + + /** + * Configured username + * + * @return the standard username that should be used for connections + */ + public String getUsername() { + return this.username; + } + + /** + * Configured password + * + * @return the standard password that should be used for connections + */ + public String getPassword() { + return this.password; + } +} diff --git a/modules/solace/src/test/java/org/testcontainers/solace/SolaceContainerAMQPTest.java b/modules/solace/src/test/java/org/testcontainers/solace/SolaceContainerAMQPTest.java new file mode 100644 index 00000000000..e120534559b --- /dev/null +++ b/modules/solace/src/test/java/org/testcontainers/solace/SolaceContainerAMQPTest.java @@ -0,0 +1,97 @@ +package org.testcontainers.solace; + +import org.apache.qpid.jms.JmsConnectionFactory; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import javax.jms.Connection; +import javax.jms.ConnectionFactory; +import javax.jms.JMSException; +import javax.jms.MessageConsumer; +import javax.jms.MessageProducer; +import javax.jms.Session; +import javax.jms.TextMessage; +import javax.jms.Topic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +class SolaceContainerAMQPTest { + + private static final Logger LOGGER = LoggerFactory.getLogger(SolaceContainerAMQPTest.class); + + private static final String MESSAGE = "HelloWorld"; + + private static final String TOPIC_NAME = "Topic/ActualTopic"; + + @Test + void testSolaceContainer() throws JMSException { + try ( + SolaceContainer solaceContainer = new SolaceContainer("solace/solace-pubsub-standard:10.25.0") + .withTopic(TOPIC_NAME, Service.AMQP) + .withVpn("amqp-vpn") + ) { + solaceContainer.start(); + // solaceContainerUsage { + Session session = createSession( + solaceContainer.getUsername(), + solaceContainer.getPassword(), + solaceContainer.getOrigin(Service.AMQP) + ); + // } + assertThat(session).isNotNull(); + assertThat(consumeMessageFromSolace(session)).isEqualTo(MESSAGE); + session.close(); + } + } + + private static Session createSession(String username, String password, String host) { + try { + ConnectionFactory connectionFactory = new JmsConnectionFactory(username, password, host); + Connection connection = connectionFactory.createConnection(); + Session session = connection.createSession(); + connection.start(); + return session; + } catch (Exception e) { + fail("Error connecting and setting up session! " + e.getMessage()); + return null; + } + } + + private void publishMessageToSolace(Session session, Topic topic) throws JMSException { + MessageProducer messageProducer = session.createProducer(topic); + TextMessage message = session.createTextMessage(MESSAGE); + messageProducer.send(message); + messageProducer.close(); + } + + private String consumeMessageFromSolace(Session session) { + CountDownLatch latch = new CountDownLatch(1); + try { + String[] result = new String[1]; + Topic topic = session.createTopic(TOPIC_NAME); + MessageConsumer messageConsumer = session.createConsumer(topic); + messageConsumer.setMessageListener(message -> { + try { + if (message instanceof TextMessage) { + result[0] = ((TextMessage) message).getText(); + } + latch.countDown(); + } catch (Exception e) { + LOGGER.error("Exception received: " + e.getMessage()); + latch.countDown(); + } + }); + publishMessageToSolace(session, topic); + assertThat(latch.await(10L, TimeUnit.SECONDS)).isTrue(); + messageConsumer.close(); + return result[0]; + } catch (Exception e) { + throw new RuntimeException("Cannot receive message from solace", e); + } + } +} diff --git a/modules/solace/src/test/java/org/testcontainers/solace/SolaceContainerMQTTTest.java b/modules/solace/src/test/java/org/testcontainers/solace/SolaceContainerMQTTTest.java new file mode 100644 index 00000000000..652f216f6d2 --- /dev/null +++ b/modules/solace/src/test/java/org/testcontainers/solace/SolaceContainerMQTTTest.java @@ -0,0 +1,96 @@ +package org.testcontainers.solace; + +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +class SolaceContainerMQTTTest { + + private static final Logger LOGGER = LoggerFactory.getLogger(SolaceContainerMQTTTest.class); + + private static final String MESSAGE = "HelloWorld"; + + private static final String TOPIC_NAME = "Topic/ActualTopic"; + + @Test + void testSolaceContainer() { + try ( + SolaceContainer solaceContainer = new SolaceContainer("solace/solace-pubsub-standard:10.25.0") + .withTopic(TOPIC_NAME, Service.MQTT) + .withVpn("mqtt-vpn") + ) { + solaceContainer.start(); + MqttClient client = createClient( + solaceContainer.getUsername(), + solaceContainer.getPassword(), + solaceContainer.getOrigin(Service.MQTT) + ); + assertThat(client).isNotNull(); + assertThat(consumeMessageFromSolace(client)).isEqualTo(MESSAGE); + } + } + + private static MqttClient createClient(String username, String password, String host) { + try { + MqttClient mqttClient = new MqttClient(host, MESSAGE); + MqttConnectOptions connOpts = new MqttConnectOptions(); + connOpts.setCleanSession(true); + connOpts.setUserName(username); + connOpts.setPassword(password.toCharArray()); + mqttClient.connect(connOpts); + return mqttClient; + } catch (Exception e) { + fail("Error connecting and setting up session! " + e.getMessage()); + return null; + } + } + + private void publishMessageToSolace(MqttClient mqttClient) throws MqttException { + MqttMessage message = new MqttMessage(MESSAGE.getBytes()); + message.setQos(0); + mqttClient.publish(TOPIC_NAME, message); + } + + private String consumeMessageFromSolace(MqttClient client) { + CountDownLatch latch = new CountDownLatch(1); + try { + String[] result = new String[1]; + client.setCallback( + new MqttCallback() { + @Override + public void connectionLost(Throwable cause) { + LOGGER.error("Exception received: " + cause.getMessage()); + latch.countDown(); + } + + @Override + public void messageArrived(String topic, MqttMessage message) { + result[0] = new String(message.getPayload()); + latch.countDown(); + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) {} + } + ); + client.subscribe(TOPIC_NAME, 0); + publishMessageToSolace(client); + assertThat(latch.await(10L, TimeUnit.SECONDS)).isTrue(); + return result[0]; + } catch (Exception e) { + throw new RuntimeException("Cannot receive message from solace", e); + } + } +} diff --git a/modules/solace/src/test/java/org/testcontainers/solace/SolaceContainerRESTTest.java b/modules/solace/src/test/java/org/testcontainers/solace/SolaceContainerRESTTest.java new file mode 100644 index 00000000000..3d6914027c1 --- /dev/null +++ b/modules/solace/src/test/java/org/testcontainers/solace/SolaceContainerRESTTest.java @@ -0,0 +1,60 @@ +package org.testcontainers.solace; + +import org.apache.http.HttpHeaders; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.util.EntityUtils; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +class SolaceContainerRESTTest { + + private static final String MESSAGE = "HelloWorld"; + + private static final String TOPIC_NAME = "Topic/ActualTopic"; + + @Test + void testSolaceContainer() throws IOException { + try ( + SolaceContainer solaceContainer = new SolaceContainer("solace/solace-pubsub-standard:10.25.0") + .withTopic(TOPIC_NAME, Service.REST) + .withVpn("rest-vpn") + ) { + solaceContainer.start(); + testPublishMessageToSolace(solaceContainer, Service.REST); + } + } + + private void testPublishMessageToSolace(SolaceContainer solaceContainer, Service service) throws IOException { + HttpClient client = createClient(solaceContainer); + HttpPost request = new HttpPost(solaceContainer.getOrigin(service) + "/" + TOPIC_NAME); + request.setEntity(new StringEntity(MESSAGE)); + request.addHeader(HttpHeaders.CONTENT_TYPE, "text/plain"); + HttpResponse response = client.execute(request); + if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) { + fail("Cannot send message to solace - " + EntityUtils.toString(response.getEntity())); + } + assertThat(EntityUtils.toString(response.getEntity())).isEmpty(); + } + + private HttpClient createClient(SolaceContainer solaceContainer) { + CredentialsProvider provider = new BasicCredentialsProvider(); + provider.setCredentials( + AuthScope.ANY, + new UsernamePasswordCredentials(solaceContainer.getUsername(), solaceContainer.getPassword()) + ); + return HttpClientBuilder.create().setDefaultCredentialsProvider(provider).build(); + } +} diff --git a/modules/solace/src/test/java/org/testcontainers/solace/SolaceContainerSMFTest.java b/modules/solace/src/test/java/org/testcontainers/solace/SolaceContainerSMFTest.java new file mode 100644 index 00000000000..f4d73ea08f0 --- /dev/null +++ b/modules/solace/src/test/java/org/testcontainers/solace/SolaceContainerSMFTest.java @@ -0,0 +1,217 @@ +package org.testcontainers.solace; + +import com.solacesystems.jcsmp.BytesXMLMessage; +import com.solacesystems.jcsmp.ConsumerFlowProperties; +import com.solacesystems.jcsmp.EndpointProperties; +import com.solacesystems.jcsmp.JCSMPException; +import com.solacesystems.jcsmp.JCSMPFactory; +import com.solacesystems.jcsmp.JCSMPProperties; +import com.solacesystems.jcsmp.JCSMPSession; +import com.solacesystems.jcsmp.JCSMPStreamingPublishCorrelatingEventHandler; +import com.solacesystems.jcsmp.Queue; +import com.solacesystems.jcsmp.TextMessage; +import com.solacesystems.jcsmp.Topic; +import com.solacesystems.jcsmp.XMLMessageConsumer; +import com.solacesystems.jcsmp.XMLMessageListener; +import com.solacesystems.jcsmp.XMLMessageProducer; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.utility.MountableFile; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +class SolaceContainerSMFTest { + + private static final Logger LOGGER = LoggerFactory.getLogger(SolaceContainerSMFTest.class); + + private static final String MESSAGE = "HelloWorld"; + + private static final Topic TOPIC = JCSMPFactory.onlyInstance().createTopic("Topic/ActualTopic"); + + private static final Queue QUEUE = JCSMPFactory.onlyInstance().createQueue("Queue"); + + @Test + void testSolaceContainerWithSimpleAuthentication() { + try ( + // solaceContainerSetup { + SolaceContainer solaceContainer = new SolaceContainer("solace/solace-pubsub-standard:10.25.0") + .withCredentials("user", "pass") + .withTopic(TOPIC.getName(), Service.SMF) + .withVpn("test_vpn") + // } + ) { + solaceContainer.start(); + JCSMPSession session = createSessionWithBasicAuth(solaceContainer); + assertThat(session).isNotNull(); + consumeMessageFromTopics(session); + session.closeSession(); + } + } + + @Test + void testSolaceContainerWithCreateFlow() { + try ( + SolaceContainer solaceContainer = new SolaceContainer("solace/solace-pubsub-standard:10.25.0") + .withCredentials("user", "pass") + .withTopic(TOPIC.getName(), Service.SMF) + .withVpn("test_vpn") + ) { + solaceContainer.start(); + JCSMPSession session = createSessionWithBasicAuth(solaceContainer); + assertThat(session).isNotNull(); + testCreateFlow(session); + session.closeSession(); + } + } + + private static void testCreateFlow(JCSMPSession session) { + try { + EndpointProperties endpointProperties = new EndpointProperties(); + endpointProperties.setAccessType(EndpointProperties.ACCESSTYPE_NONEXCLUSIVE); + endpointProperties.setQuota(1000); + session.provision(QUEUE, endpointProperties, JCSMPSession.FLAG_IGNORE_ALREADY_EXISTS); + session.addSubscription(QUEUE, TOPIC, JCSMPSession.WAIT_FOR_CONFIRM); + ConsumerFlowProperties flowProperties = new ConsumerFlowProperties().setEndpoint(QUEUE); + TestConsumer listener = new TestConsumer(); + session.createFlow(listener, flowProperties).start(); + publishMessageToSolaceTopic(session); + listener.waitForMessage(); + } catch (Exception e) { + throw new RuntimeException("Cannot process message using solace topic/queue: " + e.getMessage(), e); + } + } + + @Test + void testSolaceContainerWithCertificates() { + try ( + // solaceContainerUsageSSL { + SolaceContainer solaceContainer = new SolaceContainer("solace/solace-pubsub-standard:10.25.0") + .withClientCert( + MountableFile.forClasspathResource("solace.pem"), + MountableFile.forClasspathResource("rootCA.crt") + ) + .withTopic(TOPIC.getName(), Service.SMF_SSL) + // } + ) { + solaceContainer.start(); + JCSMPSession session = createSessionWithCertificates(solaceContainer); + assertThat(session).isNotNull(); + consumeMessageFromTopics(session); + session.closeSession(); + } + } + + private String getResourceFileLocation(String name) { + return getClass().getClassLoader().getResource(name).getPath(); + } + + private static JCSMPSession createSessionWithBasicAuth(SolaceContainer solace) { + JCSMPProperties properties = new JCSMPProperties(); + properties.setProperty(JCSMPProperties.HOST, solace.getOrigin(Service.SMF)); + properties.setProperty(JCSMPProperties.VPN_NAME, solace.getVpn()); + properties.setProperty(JCSMPProperties.USERNAME, solace.getUsername()); + properties.setProperty(JCSMPProperties.PASSWORD, solace.getPassword()); + return createSession(properties); + } + + private JCSMPSession createSessionWithCertificates(SolaceContainer solace) { + JCSMPProperties properties = new JCSMPProperties(); + properties.setProperty(JCSMPProperties.HOST, solace.getOrigin(Service.SMF_SSL)); + properties.setProperty(JCSMPProperties.VPN_NAME, solace.getVpn()); + properties.setProperty(JCSMPProperties.USERNAME, solace.getUsername()); + // Just for testing purposes + properties.setProperty(JCSMPProperties.SSL_VALIDATE_CERTIFICATE_HOST, false); + properties.setProperty(JCSMPProperties.SSL_VALIDATE_CERTIFICATE, true); + properties.setProperty(JCSMPProperties.SSL_VALIDATE_CERTIFICATE_DATE, true); + properties.setProperty( + JCSMPProperties.AUTHENTICATION_SCHEME, + JCSMPProperties.AUTHENTICATION_SCHEME_CLIENT_CERTIFICATE + ); + properties.setProperty(JCSMPProperties.SSL_TRUST_STORE, getResourceFileLocation("truststore")); + properties.setProperty(JCSMPProperties.SSL_TRUST_STORE_PASSWORD, "solace"); + properties.setProperty(JCSMPProperties.SSL_KEY_STORE, getResourceFileLocation("client.pfx")); + properties.setProperty(JCSMPProperties.SSL_KEY_STORE_PASSWORD, "solace"); + return createSession(properties); + } + + private static JCSMPSession createSession(JCSMPProperties properties) { + try { + JCSMPSession session = JCSMPFactory.onlyInstance().createSession(properties); + session.connect(); + return session; + } catch (Exception e) { + fail("Error connecting and setting up session! " + e.getMessage()); + return null; + } + } + + private static void publishMessageToSolaceTopic(JCSMPSession session) throws JCSMPException { + XMLMessageProducer producer = session.getMessageProducer( + new JCSMPStreamingPublishCorrelatingEventHandler() { + @Override + public void responseReceivedEx(Object o) { + LOGGER.info("Producer received response for msg: " + o); + } + + @Override + public void handleErrorEx(Object o, JCSMPException e, long l) { + LOGGER.error(String.format("Producer received error for msg: %s - %s", o, e)); + } + } + ); + TextMessage msg = producer.createTextMessage(); + msg.setText(MESSAGE); + producer.send(msg, TOPIC); + } + + private static void consumeMessageFromTopics(JCSMPSession session) { + try { + TestConsumer listener = new TestConsumer(); + XMLMessageConsumer cons = session.getMessageConsumer(listener); + session.addSubscription(TOPIC); + cons.start(); + publishMessageToSolaceTopic(session); + listener.waitForMessage(); + } catch (Exception e) { + throw new RuntimeException("Cannot process message using solace: " + e.getMessage(), e); + } + } + + static class TestConsumer implements XMLMessageListener { + + private final CountDownLatch latch = new CountDownLatch(1); + + private String result; + + @Override + public void onReceive(BytesXMLMessage msg) { + if (msg instanceof TextMessage) { + TextMessage textMessage = (TextMessage) msg; + String message = textMessage.getText(); + result = message; + LOGGER.info("Message received: " + message); + } + latch.countDown(); + } + + @Override + public void onException(JCSMPException e) { + LOGGER.error("Exception received: " + e.getMessage()); + latch.countDown(); + } + + private void waitForMessage() { + try { + assertThat(latch.await(10L, TimeUnit.SECONDS)).isTrue(); + assertThat(result).isEqualTo(MESSAGE); + } catch (Exception e) { + throw new RuntimeException("Cannot receive message from solace: " + e.getMessage(), e); + } + } + } +} diff --git a/modules/solace/src/test/resources/client.pfx b/modules/solace/src/test/resources/client.pfx new file mode 100644 index 00000000000..e4d0a8de449 Binary files /dev/null and b/modules/solace/src/test/resources/client.pfx differ diff --git a/modules/solace/src/test/resources/logback-test.xml b/modules/solace/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/solace/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/solace/src/test/resources/rootCA.crt b/modules/solace/src/test/resources/rootCA.crt new file mode 100644 index 00000000000..f30b3663471 --- /dev/null +++ b/modules/solace/src/test/resources/rootCA.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDmjCCAoKgAwIBAgIIVP5+fnVAfbwwDQYJKoZIhvcNAQELBQAwRjELMAkGA1UE +BhMCUEwxFzAVBgNVBAoTDnRlc3Rjb250YWluZXJzMR4wHAYDVQQDExV0ZXN0Y29u +dGFpbmVycy1zb2xhY2UwIBcNMjQwMTI0MTYyNDAwWhgPMjIwMDAxMDEwMDAwMDBa +MEYxCzAJBgNVBAYTAlBMMRcwFQYDVQQKEw50ZXN0Y29udGFpbmVyczEeMBwGA1UE +AxMVdGVzdGNvbnRhaW5lcnMtc29sYWNlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAr26PCDd4C7BsSh2aMn/c7ZLLX44FnQE2JqakGGt9JGzkWDQaTAJk +8HlBnRvJAq03RsOq0hazhbW6NGOt//SuMqS0WxT/p8wyIaIMZy8H/nCJm4Uw88/i +AG9rE/mQJpwNucrB3FBVihDBZRystYyO781dgOK7+lnW6YKXJ3jRK/QJOkuJs3F1 +jtgcZJHNwCoy7hRzWzPKEjb67aGDRVs6Iq6TL94AIEw4Qdb+x9g7Vtdgzj6M9stJ +AKJe9TJRTAbVoKRw6ROOSbpYFePS3DI71bS87Ho2erXBRSovnfioh4u7bZH4XPwu +MIZvfeSVoKiIk2anReD+tM+zI/iU5bmCwQIDAQABo4GJMIGGMA8GA1UdEwEB/wQF +MAMBAf8wHQYDVR0OBBYEFNvFeVvpx8cEIIcjvDtQIOIPr9W8MAsGA1UdDwQEAwIC +jDAUBgNVHREEDTALgglsb2NhbGhvc3QwEQYJYIZIAYb4QgEBBAQDAgAHMB4GCWCG +SAGG+EIBDQQRFg94Y2EgY2VydGlmaWNhdGUwDQYJKoZIhvcNAQELBQADggEBAGiI +7qyjIx6ssKi04pKi0cuUZWI+JeY/+nkLEXN9dgbSchFcVJMpV8l9VVqINNFt9LMX +aTzQLnjUYop6osHA16zF9WViVJ8LUzwec70A4J5gA3tiebLNWCoAp7v5+dRPUc8r +0oi4QTP4Siu6kVjZFNWuuezUZyaVz+rZuEe+rVWyNakIiAzIacaK3FBAtcCYcF2L +2CjHCCsrRgmmvkRydGsr0pJEub9wxY4kVzQS07CwB0OMxllZm65ZtVdpPWh4f3GG +/bB6kL9InDfHfSleKG60HpEPGqwWFm6VQxQVIOoumyP1WDp3ix+u0u5Y/Mo6hakm +LplIUWZWT4rv/NaB/Rw= +-----END CERTIFICATE----- diff --git a/modules/solace/src/test/resources/solace.pem b/modules/solace/src/test/resources/solace.pem new file mode 100644 index 00000000000..08afbf7b45e --- /dev/null +++ b/modules/solace/src/test/resources/solace.pem @@ -0,0 +1,49 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA43Us+KsRZ218C8JAzB7LiF5dhx95F/spQzkiFgSNJ97215Go +SIPzDWm1ac4wTGZR1YHiTk2PEFHk21TeJi5sODGHqR0G0Qr2IweSz52Zs5h0p+Xw +O3x55In/Dx+jIMdOR9HfuU05WlYSTWXdwFyq9VmmXK+ViJ0pZE9X18E1Qtm27S6v +FIckdEKswh17DCKMQ6kRMDkLwe7yOoBYPAFw0R46o3PXAuVHbkI/3ewn/aopOQ+4 +I0dqTxp7m7spdIanT+oSd5EN4G1a/oTYPO67qbcq/SIgWLHR/iT2dOE28ftAco64 ++N5ds5wz5a0JL7CwIdxcgSsndsBSR+hM5cC5kwIDAQABAoIBAERwMkrT9hWfrK5B +EYwZS/ZJJm0MvDvJ931hiG8FiY9QmAb+rZq8EPqdLteaEZA7TS4nuXcEASLQ8UJJ +Q9pLJ4a06HOq5y0o1ixuD+9mJSQToC4Qknrjli18lADx7Pxk25nifSVdJf+XXERr +fRBvEYVnJxZGQoDrgNPMx8qEOMlW9Wub9+O5OttsMdxpVeWw/G36mT8JOVZvIw9W +frRXNuX9F17lJZIt3E10QrmKSdyogPdTMXBTTw5TCIMck1KH+lRSwP7GRnJMHKNE +bcq+yoePYHdWfIW87I8bFprgC3IyCumA+ERp3oWNvTEziXkt1bogWx4pe+tFAhIp +flpCDFkCgYEA8jhvgqEYuX9I7tY5gJHeC5BOg7d0XNpP4RN9cIEYkamgcwil7DAf +hAn2+Lzvf1wcBtFi4wyygCsXsdP3CeV60vFYHWrgXozlZVs5NIDTb4CWNbylSapR +mlfhfCQQAGXP8WdKQdoXsGhSgbX3kuAaW7d9BscPgPUSqCbSMvuRKt8CgYEA8GW+ +agfK7EDRbYbLRNcof4UIOnDoZ+vi62tbQ0wRI9dt0dXDgyeUuPMoeD5uTSu1jcjf +T59UIKHwZO5f6A7ADZeJLU72Oo4dCYxG3H3ZM1VwW6kqVlWOtBkwOoibb5ApUjEP +jVCtvRNAn+htDO+4nhQDK2xwgJnkOaXCWFZgO80CgYBjPkRSHXdn6YMUeKmuyBVW +X5YL2crPkJNSAQ5QXlSWug2HlG+HSmBfVUXfvGnUoQTKtlfx923bncxjjBmX8HJW +o5Qa2YN8ufXzhWD25iG7edARzG1ctXAh8QfuOUhlIVIF8vA18wnpuZS0mL4La87g +7VlIwZ7Uk5VFWEKfqPtduQKBgQDgqjWKYj4DDZCsC41siKgQhQNrmpmYhZtM6Mgh +3LUoCe1Yba6KpDMZpiXsOmxbMr46A8CvaPf2h2Fi8mQvO5nBGh3ZejIkByyb/705 +02Np1i9rem1Wwh7bsa6hBYo+eTwk1DT0nLHCQnvi9hT0QhUHpyxPKMj7ZtckCQXY +COFnAQKBgQCnPzxlGmmv9ktOgDN6pOzTd3VLtthQu+9u7AeumUjC4al69ROfqNW2 +dfzJcdlJqMDfuHDFle+fO0Ahfp3j/BYSZFf7jAzo/jMVh32siYiq6C6MawzknwsS +nUBfPFBD84Fkv5YaD0TT48p7YUANFL18Roq1PjezPdmkn7T8Nuaz2g== +-----END RSA PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIDqTCCApGgAwIBAgIIIFE70OcBFc8wDQYJKoZIhvcNAQELBQAwRjELMAkGA1UE +BhMCUEwxFzAVBgNVBAoTDnRlc3Rjb250YWluZXJzMR4wHAYDVQQDExV0ZXN0Y29u +dGFpbmVycy1zb2xhY2UwIBcNMjQwMTI0MTYyNDAwWhgPMjIwMDAxMDEwMDAwMDBa +MDkxFzAVBgNVBAoTDnRlc3Rjb250YWluZXJzMR4wHAYDVQQDExV0ZXN0Y29udGFp +bmVycy1zb2xhY2UwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDjdSz4 +qxFnbXwLwkDMHsuIXl2HH3kX+ylDOSIWBI0n3vbXkahIg/MNabVpzjBMZlHVgeJO +TY8QUeTbVN4mLmw4MYepHQbRCvYjB5LPnZmzmHSn5fA7fHnkif8PH6Mgx05H0d+5 +TTlaVhJNZd3AXKr1WaZcr5WInSlkT1fXwTVC2bbtLq8UhyR0QqzCHXsMIoxDqREw +OQvB7vI6gFg8AXDRHjqjc9cC5UduQj/d7Cf9qik5D7gjR2pPGnubuyl0hqdP6hJ3 +kQ3gbVr+hNg87ruptyr9IiBYsdH+JPZ04Tbx+0Byjrj43l2znDPlrQkvsLAh3FyB +Kyd2wFJH6EzlwLmTAgMBAAGjgaUwgaIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQU +T5AeIO1T10daj92c9R0qALbPu+cwCwYDVR0PBAQDAgPoMB0GA1UdJQQWMBQGCCsG +AQUFBwMBBggrBgEFBQcDAjAUBgNVHREEDTALgglsb2NhbGhvc3QwEQYJYIZIAYb4 +QgEBBAQDAgZAMB4GCWCGSAGG+EIBDQQRFg94Y2EgY2VydGlmaWNhdGUwDQYJKoZI +hvcNAQELBQADggEBADyiXuXEx/Cka/xDxUmluYprTMubhf2Tt840WtXODoqa6mK0 +L74M18fvOeRL6G1ufzyrOUg5kt+0vyS8LlNmPJ/F7RbFvJ7Ca+vFVChi6NLag5U7 +Xp6bdI7jwLKiP1Sh+AyucZafW9k+e3PO4beAJqYHmYxsLtXmnYlWFL/6VoYACpTu +6qFYenAecqQAs49Ujs5y9l0IRC8Hj9ZcPVf63BTceAdiPqwUhIctD4vB6v1pTwjb +CaQxJLY5P3sQNkvMMSK29p03EJOMZ4oVt09RBBEm1YwwUA5pp/JaxJvRlMgnTNHs +h6xQyX9XEWS9cNUa2VktBEq1eoIg/njpnbiGvDo= +-----END CERTIFICATE----- diff --git a/modules/solace/src/test/resources/truststore b/modules/solace/src/test/resources/truststore new file mode 100644 index 00000000000..153079a4242 Binary files /dev/null and b/modules/solace/src/test/resources/truststore differ diff --git a/modules/solr/build.gradle b/modules/solr/build.gradle new file mode 100644 index 00000000000..509bce7c66f --- /dev/null +++ b/modules/solr/build.gradle @@ -0,0 +1,9 @@ +description = "Testcontainers :: Solr" + +dependencies { + api project(':testcontainers') + // TODO use JDK's HTTP client and/or Apache HttpClient5 + shaded 'com.squareup.okhttp3:okhttp:5.4.0' + + testImplementation 'org.apache.solr:solr-solrj:8.11.4' +} diff --git a/modules/solr/src/main/java/org/testcontainers/containers/SolrClientUtils.java b/modules/solr/src/main/java/org/testcontainers/containers/SolrClientUtils.java new file mode 100644 index 00000000000..19f0c7c4a8c --- /dev/null +++ b/modules/solr/src/main/java/org/testcontainers/containers/SolrClientUtils.java @@ -0,0 +1,136 @@ +package org.testcontainers.containers; + +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.apache.commons.io.IOUtils; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Utils class which can create collections and configurations. + */ +public class SolrClientUtils { + + private static OkHttpClient httpClient = new OkHttpClient(); + + /** + * Creates a new configuration and uploads the solrconfig.xml and schema.xml + * + * @param hostname the Hostname under which solr is reachable + * @param port the Port on which solr is running + * @param configurationName the name of the configuration which should be created + * @param solrConfig the url under which the solrconfig.xml can be found + * @param solrSchema the url under which the schema.xml can be found or null if the default schema should be used + */ + public static void uploadConfiguration( + String hostname, + int port, + String configurationName, + URL solrConfig, + URL solrSchema + ) throws URISyntaxException, IOException { + Map parameters = new HashMap<>(); + parameters.put("action", "UPLOAD"); + parameters.put("name", configurationName); + HttpUrl url = generateSolrURL(hostname, port, Arrays.asList("admin", "configs"), parameters); + + byte[] configurationZipFile = generateConfigZipFile(solrConfig, solrSchema); + executePost(url, configurationZipFile); + } + + /** + * Creates a new collection + * + * @param hostname the Hostname under which solr is reachable + * @param port The Port on which solr is running + * @param collectionName the name of the collection which should be created + * @param configurationName the name of the configuration which should used to create the collection + * or null if the default configuration should be used + */ + public static void createCollection(String hostname, int port, String collectionName, String configurationName) + throws URISyntaxException, IOException { + Map parameters = new HashMap<>(); + parameters.put("action", "CREATE"); + parameters.put("name", collectionName); + parameters.put("numShards", "1"); + parameters.put("replicationFactor", "1"); + parameters.put("wt", "json"); + if (configurationName != null) { + parameters.put("collection.configName", configurationName); + } + HttpUrl url = generateSolrURL(hostname, port, Arrays.asList("admin", "collections"), parameters); + executePost(url, null); + } + + private static void executePost(HttpUrl url, byte[] data) throws IOException { + RequestBody requestBody = data == null + ? RequestBody.create(MediaType.parse("text/plain"), "") + : RequestBody.create(MediaType.parse("application/octet-stream"), data); + Request request = new Request.Builder().url(url).post(requestBody).build(); + + Response response = httpClient.newCall(request).execute(); + if (!response.isSuccessful()) { + String responseBody = ""; + if (response.body() != null) { + responseBody = response.body().string(); + response.close(); + } + throw new SolrClientUtilsException(response.code(), "Unable to upload binary\n" + responseBody); + } + if (response.body() != null) { + response.close(); + } + } + + private static HttpUrl generateSolrURL( + String hostname, + int port, + List pathSegments, + Map parameters + ) throws URISyntaxException { + HttpUrl.Builder builder = new HttpUrl.Builder(); + builder.scheme("http"); + builder.host(hostname); + builder.port(port); + // Path + builder.addPathSegment("solr"); + if (pathSegments != null) { + pathSegments.forEach(builder::addPathSegment); + } + // Query Parameters + parameters.forEach(builder::addQueryParameter); + return builder.build(); + } + + private static byte[] generateConfigZipFile(URL solrConfiguration, URL solrSchema) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ZipOutputStream zipOutputStream = new ZipOutputStream(bos); + // SolrConfig + zipOutputStream.putNextEntry(new ZipEntry("solrconfig.xml")); + IOUtils.copy(solrConfiguration.openStream(), zipOutputStream); + zipOutputStream.closeEntry(); + + // Solr Schema + if (solrSchema != null) { + zipOutputStream.putNextEntry(new ZipEntry("schema.xml")); + IOUtils.copy(solrSchema.openStream(), zipOutputStream); + zipOutputStream.closeEntry(); + } + + zipOutputStream.close(); + return bos.toByteArray(); + } +} diff --git a/modules/solr/src/main/java/org/testcontainers/containers/SolrClientUtilsException.java b/modules/solr/src/main/java/org/testcontainers/containers/SolrClientUtilsException.java new file mode 100644 index 00000000000..a3b2cfd77c6 --- /dev/null +++ b/modules/solr/src/main/java/org/testcontainers/containers/SolrClientUtilsException.java @@ -0,0 +1,8 @@ +package org.testcontainers.containers; + +public class SolrClientUtilsException extends RuntimeException { + + public SolrClientUtilsException(int statusCode, String msg) { + super("Http Call Status: " + statusCode + "\n" + msg); + } +} diff --git a/modules/solr/src/main/java/org/testcontainers/containers/SolrContainer.java b/modules/solr/src/main/java/org/testcontainers/containers/SolrContainer.java new file mode 100644 index 00000000000..a921eb2f458 --- /dev/null +++ b/modules/solr/src/main/java/org/testcontainers/containers/SolrContainer.java @@ -0,0 +1,168 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import lombok.SneakyThrows; +import org.apache.commons.lang3.StringUtils; +import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; + +import java.net.URL; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.HashSet; +import java.util.Set; + +/** + * Testcontainers implementation for Solr. + *

+ * Supported image: {@code solr} + *

+ * Exposed ports: + *

    + *
  • Solr: 8983
  • + *
  • Zookeeper: 9983
  • + *
+ * + * @deprecated use {@link org.testcontainers.solr.SolrContainer} instead. + */ +public class SolrContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("solr"); + + @Deprecated + public static final String DEFAULT_TAG = "8.3.0"; + + public static final Integer ZOOKEEPER_PORT = 9983; + + public static final Integer SOLR_PORT = 8983; + + private SolrContainerConfiguration configuration; + + private final ComparableVersion imageVersion; + + /** + * @deprecated use {@link #SolrContainer(DockerImageName)} instead + */ + @Deprecated + public SolrContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + public SolrContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public SolrContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + this.waitStrategy = + new LogMessageWaitStrategy() + .withRegEx(".*o\\.e\\.j\\.s\\.Server Started.*") + .withStartupTimeout(Duration.of(60, ChronoUnit.SECONDS)); + this.configuration = new SolrContainerConfiguration(); + this.imageVersion = new ComparableVersion(dockerImageName.getVersionPart()); + } + + public SolrContainer withZookeeper(boolean zookeeper) { + configuration.setZookeeper(zookeeper); + return self(); + } + + public SolrContainer withCollection(String collection) { + if (StringUtils.isEmpty(collection)) { + throw new IllegalArgumentException("Collection name must not be empty"); + } + configuration.setCollectionName(collection); + return self(); + } + + public SolrContainer withConfiguration(String name, URL solrConfig) { + if (StringUtils.isEmpty(name) || solrConfig == null) { + throw new IllegalArgumentException(); + } + configuration.setConfigurationName(name); + configuration.setSolrConfiguration(solrConfig); + return self(); + } + + public SolrContainer withSchema(URL schema) { + configuration.setSolrSchema(schema); + return self(); + } + + public int getSolrPort() { + return getMappedPort(SOLR_PORT); + } + + public int getZookeeperPort() { + return getMappedPort(ZOOKEEPER_PORT); + } + + @Override + @SneakyThrows + protected void configure() { + if (configuration.getSolrSchema() != null && configuration.getSolrConfiguration() == null) { + throw new IllegalStateException("Solr needs to have a configuration if you want to use a schema"); + } + // Generate Command Builder + String command = "solr start -f"; + // Add Default Ports + this.addExposedPort(SOLR_PORT); + + // Configure Zookeeper + if (configuration.isZookeeper()) { + this.addExposedPort(ZOOKEEPER_PORT); + if (this.imageVersion.isGreaterThanOrEqualTo("9.7.0")) { + command = "-DzkRun --host localhost"; + } else { + command = "-DzkRun -h localhost"; + } + } + + // Apply generated Command + this.setCommand(command); + } + + @Override + public Set getLivenessCheckPortNumbers() { + return new HashSet<>(getSolrPort()); + } + + @Override + protected void waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this); + } + + @Override + @SneakyThrows + protected void containerIsStarted(InspectContainerResponse containerInfo) { + if (!configuration.isZookeeper()) { + ExecResult result = execInContainer("solr", "create", "-c", configuration.getCollectionName()); + if (result.getExitCode() != 0) { + throw new IllegalStateException( + "Unable to create solr core:\nStdout: " + result.getStdout() + "\nStderr:" + result.getStderr() + ); + } + return; + } + + if (StringUtils.isNotEmpty(configuration.getConfigurationName())) { + SolrClientUtils.uploadConfiguration( + getHost(), + getSolrPort(), + configuration.getConfigurationName(), + configuration.getSolrConfiguration(), + configuration.getSolrSchema() + ); + } + + SolrClientUtils.createCollection( + getHost(), + getSolrPort(), + configuration.getCollectionName(), + configuration.getConfigurationName() + ); + } +} diff --git a/modules/solr/src/main/java/org/testcontainers/containers/SolrContainerConfiguration.java b/modules/solr/src/main/java/org/testcontainers/containers/SolrContainerConfiguration.java new file mode 100644 index 00000000000..24035c8f4a9 --- /dev/null +++ b/modules/solr/src/main/java/org/testcontainers/containers/SolrContainerConfiguration.java @@ -0,0 +1,19 @@ +package org.testcontainers.containers; + +import lombok.Data; + +import java.net.URL; + +@Data +public class SolrContainerConfiguration { + + private boolean zookeeper = true; + + private String collectionName = "dummy"; + + private String configurationName; + + private URL solrConfiguration; + + private URL solrSchema; +} diff --git a/modules/solr/src/main/java/org/testcontainers/solr/SolrContainer.java b/modules/solr/src/main/java/org/testcontainers/solr/SolrContainer.java new file mode 100644 index 00000000000..536afa69dfd --- /dev/null +++ b/modules/solr/src/main/java/org/testcontainers/solr/SolrContainer.java @@ -0,0 +1,156 @@ +package org.testcontainers.solr; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import lombok.SneakyThrows; +import org.apache.commons.lang3.StringUtils; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.SolrClientUtils; +import org.testcontainers.containers.SolrContainerConfiguration; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.ComparableVersion; +import org.testcontainers.utility.DockerImageName; + +import java.net.URL; +import java.time.Duration; +import java.util.HashSet; +import java.util.Set; + +/** + * Testcontainers implementation for Solr. + *

+ * Supported image: {@code solr} + *

+ * Exposed ports: + *

    + *
  • Solr: 8983
  • + *
  • Zookeeper: 9983
  • + *
+ */ +public class SolrContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("solr"); + + public static final Integer ZOOKEEPER_PORT = 9983; + + public static final Integer SOLR_PORT = 8983; + + private SolrContainerConfiguration configuration; + + private final ComparableVersion imageVersion; + + public SolrContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public SolrContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + waitingFor( + Wait.forLogMessage(".*o\\.e\\.j\\.s\\.Server Started.*", 1).withStartupTimeout(Duration.ofMinutes(1)) + ); + this.configuration = new SolrContainerConfiguration(); + this.imageVersion = new ComparableVersion(dockerImageName.getVersionPart()); + } + + public SolrContainer withZookeeper(boolean zookeeper) { + configuration.setZookeeper(zookeeper); + return self(); + } + + public SolrContainer withCollection(String collection) { + if (StringUtils.isEmpty(collection)) { + throw new IllegalArgumentException("Collection name must not be empty"); + } + configuration.setCollectionName(collection); + return self(); + } + + public SolrContainer withConfiguration(String name, URL solrConfig) { + if (StringUtils.isEmpty(name) || solrConfig == null) { + throw new IllegalArgumentException(); + } + configuration.setConfigurationName(name); + configuration.setSolrConfiguration(solrConfig); + return self(); + } + + public SolrContainer withSchema(URL schema) { + configuration.setSolrSchema(schema); + return self(); + } + + public int getSolrPort() { + return getMappedPort(SOLR_PORT); + } + + public int getZookeeperPort() { + return getMappedPort(ZOOKEEPER_PORT); + } + + @Override + @SneakyThrows + protected void configure() { + if (configuration.getSolrSchema() != null && configuration.getSolrConfiguration() == null) { + throw new IllegalStateException("Solr needs to have a configuration if you want to use a schema"); + } + // Generate Command Builder + String command = "solr start -f"; + // Add Default Ports + addExposedPort(SOLR_PORT); + + // Configure Zookeeper + if (configuration.isZookeeper()) { + addExposedPort(ZOOKEEPER_PORT); + if (this.imageVersion.isGreaterThanOrEqualTo("9.7.0")) { + command = "-DzkRun --host localhost"; + } else { + command = "-DzkRun -h localhost"; + } + } + + // Apply generated Command + setCommand(command); + } + + @Override + public Set getLivenessCheckPortNumbers() { + return new HashSet<>(getSolrPort()); + } + + @Override + protected void waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this); + } + + @Override + @SneakyThrows + protected void containerIsStarted(InspectContainerResponse containerInfo) { + if (!configuration.isZookeeper()) { + ExecResult result = execInContainer("solr", "create", "-c", configuration.getCollectionName()); + if (result.getExitCode() != 0) { + throw new IllegalStateException( + "Unable to create solr core:\nStdout: " + result.getStdout() + "\nStderr:" + result.getStderr() + ); + } + return; + } + + if (StringUtils.isNotEmpty(configuration.getConfigurationName())) { + SolrClientUtils.uploadConfiguration( + getHost(), + getSolrPort(), + configuration.getConfigurationName(), + configuration.getSolrConfiguration(), + configuration.getSolrSchema() + ); + } + + SolrClientUtils.createCollection( + getHost(), + getSolrPort(), + configuration.getCollectionName(), + configuration.getConfigurationName() + ); + } +} diff --git a/modules/solr/src/test/java/org/testcontainers/solr/SolrContainerTest.java b/modules/solr/src/test/java/org/testcontainers/solr/SolrContainerTest.java new file mode 100644 index 00000000000..f63c4d38164 --- /dev/null +++ b/modules/solr/src/test/java/org/testcontainers/solr/SolrContainerTest.java @@ -0,0 +1,83 @@ +package org.testcontainers.solr; + +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrServerException; +import org.apache.solr.client.solrj.impl.Http2SolrClient; +import org.apache.solr.client.solrj.response.SolrPingResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +class SolrContainerTest { + + private SolrClient client = null; + + public static String[] getVersionsToTest() { + return new String[] { "solr:8.11.4", "solr:9.8.0" }; + } + + @AfterEach + void stopRestClient() throws IOException { + if (client != null) { + client.close(); + client = null; + } + } + + @ParameterizedTest + @MethodSource("getVersionsToTest") + void solrCloudTest(String solrImage) throws IOException, SolrServerException { + try (SolrContainer container = new SolrContainer(solrImage)) { + container.start(); + SolrPingResponse response = getClient(container).ping("dummy"); + assertThat(response.getStatus()).isZero(); + assertThat(response.jsonStr()).contains("zkConnected\":true"); + } + } + + @ParameterizedTest + @MethodSource("getVersionsToTest") + void solrStandaloneTest(String solrImage) throws IOException, SolrServerException { + try (SolrContainer container = new SolrContainer(solrImage).withZookeeper(false)) { + container.start(); + SolrPingResponse response = getClient(container).ping("dummy"); + assertThat(response.getStatus()).isZero(); + assertThat(response.jsonStr()).contains("zkConnected\":null"); + } + } + + @ParameterizedTest + @MethodSource("getVersionsToTest") + void solrCloudPingTest(String solrImage) throws IOException, SolrServerException { + // solrContainerUsage { + // Create the solr container. + SolrContainer container = new SolrContainer(solrImage); + + // Start the container. This step might take some time... + container.start(); + + // Do whatever you want with the client ... + SolrClient client = new Http2SolrClient.Builder( + "http://" + container.getHost() + ":" + container.getSolrPort() + "/solr" + ) + .build(); + SolrPingResponse response = client.ping("dummy"); + + // Stop the container. + container.stop(); + // } + } + + private SolrClient getClient(SolrContainer container) { + if (client == null) { + client = + new Http2SolrClient.Builder("http://" + container.getHost() + ":" + container.getSolrPort() + "/solr") + .build(); + } + return client; + } +} diff --git a/modules/solr/src/test/resources/logback-test.xml b/modules/solr/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/solr/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/spock/build.gradle b/modules/spock/build.gradle new file mode 100644 index 00000000000..2ff27ecbace --- /dev/null +++ b/modules/spock/build.gradle @@ -0,0 +1,43 @@ +plugins { + id 'groovy' +} + +description = "Testcontainers :: Spock-Extension" + +dependencies { + api project(':testcontainers') + implementation 'org.spockframework:spock-core:2.3-groovy-4.0' + + testImplementation project(':testcontainers-selenium') + testImplementation project(':testcontainers-mysql') + testImplementation project(':testcontainers-postgresql') + + testImplementation 'com.zaxxer:HikariCP:7.1.0' + testImplementation 'org.apache.httpcomponents:httpclient:4.5.14' + + testRuntimeOnly 'org.postgresql:postgresql:42.7.12' + testRuntimeOnly 'com.mysql:mysql-connector-j:9.6.0' + testRuntimeOnly platform('org.junit:junit-bom:5.14.3') + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'org.junit.platform:junit-platform-testkit' + + testCompileOnly 'org.jetbrains:annotations:26.1.0' +} + +tasks.withType(GroovyCompile) { + sourceCompatibility = '1.8' + targetCompatibility = '1.8' + options.encoding = 'UTF-8' +} + +sourceJar { + /* allJava is default (see gradle/publishing.gradle:sourceJar) + allSource contains both .java and .groovy files */ + from sourceSets.main.allSource +} + +javadocJar { + dependsOn groovydoc + archiveClassifier = 'javadoc' + from groovydoc.destinationDir +} diff --git a/modules/spock/src/main/groovy/org/testcontainers/spock/DockerAvailableDetector.groovy b/modules/spock/src/main/groovy/org/testcontainers/spock/DockerAvailableDetector.groovy new file mode 100644 index 00000000000..b64299ffa87 --- /dev/null +++ b/modules/spock/src/main/groovy/org/testcontainers/spock/DockerAvailableDetector.groovy @@ -0,0 +1,15 @@ +package org.testcontainers.spock + +import org.testcontainers.DockerClientFactory + +class DockerAvailableDetector { + + boolean isDockerAvailable() { + try { + DockerClientFactory.instance().client(); + return true; + } catch (Throwable ex) { + return false; + } + } +} diff --git a/modules/spock/src/main/groovy/org/testcontainers/spock/SpockTestDescription.groovy b/modules/spock/src/main/groovy/org/testcontainers/spock/SpockTestDescription.groovy new file mode 100644 index 00000000000..6c71ac421c6 --- /dev/null +++ b/modules/spock/src/main/groovy/org/testcontainers/spock/SpockTestDescription.groovy @@ -0,0 +1,36 @@ +package org.testcontainers.spock + +import groovy.transform.PackageScope +import org.spockframework.runtime.extension.IMethodInvocation +import org.testcontainers.lifecycle.TestDescription + +/** + * Spock specific implementation of a Testcontainers TestDescription. + * + * Filesystem friendly name is based on Specification and Feature. + */ +@PackageScope +class SpockTestDescription implements TestDescription { + + String specName + String featureName + + static SpockTestDescription fromTestDescription(IMethodInvocation invocation) { + return new SpockTestDescription([ + specName: invocation.spec.name, + featureName: invocation.feature.name + ]) + } + + @Override + String getTestId() { + return getFilesystemFriendlyName() + } + + @Override + String getFilesystemFriendlyName() { + return [specName, featureName].collect { + URLEncoder.encode(it, 'UTF-8') + }.join('-') + } +} diff --git a/modules/spock/src/main/groovy/org/testcontainers/spock/Testcontainers.groovy b/modules/spock/src/main/groovy/org/testcontainers/spock/Testcontainers.groovy new file mode 100644 index 00000000000..98c2223904b --- /dev/null +++ b/modules/spock/src/main/groovy/org/testcontainers/spock/Testcontainers.groovy @@ -0,0 +1,64 @@ +package org.testcontainers.spock + +import org.spockframework.runtime.extension.ExtensionAnnotation + +import java.lang.annotation.ElementType +import java.lang.annotation.Inherited +import java.lang.annotation.Retention +import java.lang.annotation.RetentionPolicy +import java.lang.annotation.Target + +/** + * {@code @Testcontainers} is a Spock extension to activate automatic + * startup and stop of containers used in a test case. + * + *

The Testcontainers extension finds all fields that extend + * {@link org.testcontainers.containers.GenericContainer} or + * {@link org.testcontainers.containers.DockerComposeContainer} and calls their + * container lifecycle methods. Containers annotated with {@link spock.lang.Shared} + * will be shared between test methods. They will be + * started only once before any test method is executed and stopped after the + * last test method has executed. Containers without {@link spock.lang.Shared} + * annotation will be started and stopped for every test method.

+ * + *

The annotation {@code @Testcontainers} can be used on a superclass in + * the test hierarchy as well. All subclasses will automatically inherit + * support for the extension.

+ * + *

Example:

+ * + *
+ * @Testcontainers
+ * class MyTestcontainersTests extends Specification {
+ *
+ *     // will be started only once in setupSpec() and stopped after last test method
+ *     @Shared
+ *     MySQLContainer MY_SQL_CONTAINER = new MySQLContainer()
+ *
+ *     // will be started before and stopped after each test method
+ *     PostgreSQLContainer postgresqlContainer = new PostgreSQLContainer()
+ *             .withDatabaseName('foo')
+ *             .withUsername('foo')
+ *             .withPassword('secret')
+ *
+ *     def 'test'() {
+ *         expect:
+ *         MY_SQL_CONTAINER.running
+ *         postgresqlContainer.running
+ *     }
+ * }
+ * 
+ */ +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target([ElementType.TYPE, ElementType.METHOD]) +@ExtensionAnnotation(TestcontainersExtension) +@interface Testcontainers { + + /** + * Whether tests should be disabled (rather than failing) when Docker is not available. Defaults to + * {@code false}. + * @return if the tests should be disabled when Docker is not available + */ + boolean disabledWithoutDocker() default false; +} diff --git a/modules/spock/src/main/groovy/org/testcontainers/spock/TestcontainersExtension.groovy b/modules/spock/src/main/groovy/org/testcontainers/spock/TestcontainersExtension.groovy new file mode 100644 index 00000000000..2654408e901 --- /dev/null +++ b/modules/spock/src/main/groovy/org/testcontainers/spock/TestcontainersExtension.groovy @@ -0,0 +1,45 @@ +package org.testcontainers.spock + +import org.spockframework.runtime.AbstractRunListener +import org.spockframework.runtime.extension.AbstractAnnotationDrivenExtension +import org.spockframework.runtime.model.ErrorInfo +import org.spockframework.runtime.model.SpecInfo + +class TestcontainersExtension extends AbstractAnnotationDrivenExtension { + + private final DockerAvailableDetector dockerDetector + + TestcontainersExtension() { + this(new DockerAvailableDetector()) + } + + TestcontainersExtension(DockerAvailableDetector dockerDetector) { + this.dockerDetector = dockerDetector + } + + @Override + void visitSpecAnnotation(Testcontainers annotation, SpecInfo spec) { + if (annotation.disabledWithoutDocker()) { + if (!dockerDetector.isDockerAvailable()) { + spec.skip("disabledWithoutDocker is true and Docker is not available") + } + } + def listener = new ErrorListener() + def interceptor = new TestcontainersMethodInterceptor(spec, listener) + spec.addSetupSpecInterceptor(interceptor) + spec.addCleanupSpecInterceptor(interceptor) + spec.addSetupInterceptor(interceptor) + spec.addCleanupInterceptor(interceptor) + + spec.addListener(listener) + } + + private class ErrorListener extends AbstractRunListener { + List errors = [] + + @Override + void error(ErrorInfo error) { + errors.add(error) + } + } +} diff --git a/modules/spock/src/main/groovy/org/testcontainers/spock/TestcontainersMethodInterceptor.groovy b/modules/spock/src/main/groovy/org/testcontainers/spock/TestcontainersMethodInterceptor.groovy new file mode 100644 index 00000000000..cbef0a87bb2 --- /dev/null +++ b/modules/spock/src/main/groovy/org/testcontainers/spock/TestcontainersMethodInterceptor.groovy @@ -0,0 +1,159 @@ +package org.testcontainers.spock + +import org.spockframework.runtime.extension.AbstractMethodInterceptor +import org.spockframework.runtime.extension.IMethodInvocation +import org.spockframework.runtime.model.FieldInfo +import org.spockframework.runtime.model.SpecInfo +import org.testcontainers.containers.ComposeContainer +import org.testcontainers.containers.DockerComposeContainer +import org.testcontainers.containers.GenericContainer +import org.testcontainers.lifecycle.TestLifecycleAware +import org.testcontainers.spock.TestcontainersExtension.ErrorListener + +class TestcontainersMethodInterceptor extends AbstractMethodInterceptor { + + private final SpecInfo spec + private final ErrorListener errorListener + + TestcontainersMethodInterceptor(SpecInfo spec, ErrorListener errorListener) { + this.spec = spec + this.errorListener = errorListener + } + + @Override + void interceptSetupSpecMethod(IMethodInvocation invocation) throws Throwable { + def containers = findAllContainers(true) + startContainers(containers, invocation) + + def dockerCompose = findAllDockerComposeContainers(true) + startDockerComposeContainers(dockerCompose, invocation) + + def compose = findAllComposeContainers(true) + startComposeContainers(compose, invocation) + + invocation.proceed() + } + + @Override + void interceptCleanupSpecMethod(IMethodInvocation invocation) throws Throwable { + def containers = findAllContainers(true) + stopContainers(containers, invocation) + + def dockerCompose = findAllDockerComposeContainers(true) + stopDockerComposeContainers(dockerCompose, invocation) + + def compose = findAllComposeContainers(true) + stopComposeContainers(compose, invocation) + + invocation.proceed() + } + + @Override + void interceptSetupMethod(IMethodInvocation invocation) throws Throwable { + def containers = findAllContainers(false) + startContainers(containers, invocation) + + def dockerCompose = findAllDockerComposeContainers(false) + startDockerComposeContainers(dockerCompose, invocation) + + def compose = findAllComposeContainers(false) + startComposeContainers(compose, invocation) + + invocation.proceed() + } + + + @Override + void interceptCleanupMethod(IMethodInvocation invocation) throws Throwable { + def containers = findAllContainers(false) + stopContainers(containers, invocation) + + def dockerCompose = findAllDockerComposeContainers(false) + stopDockerComposeContainers(dockerCompose, invocation) + + def compose = findAllComposeContainers(false) + stopComposeContainers(compose, invocation) + + invocation.proceed() + } + + private List findAllContainers(boolean shared) { + spec.allFields.findAll { FieldInfo f -> + GenericContainer.isAssignableFrom(f.type) && f.shared == shared + } + } + + private List findAllDockerComposeContainers(boolean shared) { + spec.allFields.findAll { FieldInfo f -> + DockerComposeContainer.isAssignableFrom(f.type) && f.shared == shared + } + } + + private List findAllComposeContainers(boolean shared) { + spec.allFields.findAll { FieldInfo f -> + ComposeContainer.isAssignableFrom(f.type) && f.shared == shared + } + } + + private static void startContainers(List containers, IMethodInvocation invocation) { + containers.each { FieldInfo f -> + GenericContainer container = readContainerFromField(f, invocation) + if(!container.isRunning()){ + container.start() + } + + if (container instanceof TestLifecycleAware) { + def testDescription = SpockTestDescription.fromTestDescription(invocation) + (container as TestLifecycleAware).beforeTest(testDescription) + } + } + } + + private void stopContainers(List containers, IMethodInvocation invocation) { + containers.each { FieldInfo f -> + GenericContainer container = readContainerFromField(f, invocation) + + if (container instanceof TestLifecycleAware) { + // we assume first error is the one we want + def maybeException = Optional.ofNullable(errorListener.errors[0]?.exception) + def testDescription = SpockTestDescription.fromTestDescription(invocation) + (container as TestLifecycleAware).afterTest(testDescription, maybeException) + } + + container.stop() + } + } + + private static void startDockerComposeContainers(List compose, IMethodInvocation invocation) { + compose.each { FieldInfo f -> + DockerComposeContainer c = f.readValue(invocation.instance) as DockerComposeContainer + c.start() + } + } + + private static void startComposeContainers(List compose, IMethodInvocation invocation) { + compose.each { FieldInfo f -> + ComposeContainer c = f.readValue(invocation.instance) as ComposeContainer + c.start() + } + } + + private static void stopDockerComposeContainers(List compose, IMethodInvocation invocation) { + compose.each { FieldInfo f -> + DockerComposeContainer c = f.readValue(invocation.instance) as DockerComposeContainer + c.stop() + } + } + + private static void stopComposeContainers(List compose, IMethodInvocation invocation) { + compose.each { FieldInfo f -> + ComposeContainer c = f.readValue(invocation.instance) as ComposeContainer + c.stop() + } + } + + + private static GenericContainer readContainerFromField(FieldInfo f, IMethodInvocation invocation) { + f.readValue(invocation.instance) as GenericContainer + } +} diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/ComposeContainerIT.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/ComposeContainerIT.groovy new file mode 100644 index 00000000000..89de5aed88d --- /dev/null +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/ComposeContainerIT.groovy @@ -0,0 +1,37 @@ +package org.testcontainers.spock + +import org.apache.http.client.methods.HttpGet +import org.apache.http.impl.client.HttpClientBuilder +import org.testcontainers.containers.ComposeContainer +import org.testcontainers.containers.wait.strategy.Wait +import org.testcontainers.utility.DockerImageName +import spock.lang.Specification + +@Testcontainers +class ComposeContainerIT extends Specification { + + ComposeContainer composeContainer = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + new File("src/test/resources/docker-compose.yml")) + .withExposedService("whoami-1", 80, Wait.forHttp("/")) + + String host + + int port + + def setup() { + host = composeContainer.getServiceHost("whoami-1", 80) + port = composeContainer.getServicePort("whoami-1", 80) + } + + def "running compose defined container is accessible on configured port"() { + given: "a http client" + def client = HttpClientBuilder.create().build() + + when: "accessing web server" + def response = client.execute(new HttpGet("http://$host:$port")) + + then: "docker container is running and returns http status code 200" + response.statusLine.statusCode == 200 + } +} diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/DockerComposeContainerIT.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/DockerComposeContainerIT.groovy new file mode 100644 index 00000000000..bd27c18f7ab --- /dev/null +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/DockerComposeContainerIT.groovy @@ -0,0 +1,37 @@ +package org.testcontainers.spock + +import org.apache.http.client.methods.HttpGet +import org.apache.http.impl.client.HttpClientBuilder +import org.testcontainers.containers.DockerComposeContainer +import org.testcontainers.containers.wait.strategy.Wait +import org.testcontainers.utility.DockerImageName +import spock.lang.Specification + +@Testcontainers +class DockerComposeContainerIT extends Specification { + + DockerComposeContainer composeContainer = new DockerComposeContainer( + DockerImageName.parse("docker/compose:debian-1.29.2"), + new File("src/test/resources/docker-compose.yml")) + .withExposedService("whoami_1", 80, Wait.forHttp("/")) + + String host + + int port + + def setup() { + host = composeContainer.getServiceHost("whoami_1", 80) + port = composeContainer.getServicePort("whoami_1", 80) + } + + def "running compose defined container is accessible on configured port"() { + given: "a http client" + def client = HttpClientBuilder.create().build() + + when: "accessing web server" + def response = client.execute(new HttpGet("http://$host:$port")) + + then: "docker container is running and returns http status code 200" + response.statusLine.statusCode == 200 + } +} diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/MySqlContainerIT.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/MySqlContainerIT.groovy new file mode 100644 index 00000000000..2f44408fed6 --- /dev/null +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/MySqlContainerIT.groovy @@ -0,0 +1,24 @@ +package org.testcontainers.spock + +import org.testcontainers.containers.MySQLContainer +import spock.lang.Shared +import spock.lang.Specification + +/** + * This test verifies, that setup and cleanup of containers works correctly. + * It's easily achieved using the MySQLContainer, since it will fail + * if the same image is running. + * + * @see Second container is started when stopping old container + */ +@Testcontainers +class MySqlContainerIT extends Specification { + + @Shared + MySQLContainer mySQLContainer = new MySQLContainer(SpockTestImages.MYSQL_IMAGE) + + def "dummy test"() { + expect: + mySQLContainer.isRunning() + } +} diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/PostgresContainerIT.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/PostgresContainerIT.groovy new file mode 100644 index 00000000000..4b28dbef5c2 --- /dev/null +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/PostgresContainerIT.groovy @@ -0,0 +1,45 @@ +package org.testcontainers.spock + +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import org.testcontainers.containers.PostgreSQLContainer +import spock.lang.Shared +import spock.lang.Specification + +import java.sql.ResultSet +import java.sql.Statement + +// PostgresContainerIT { +@Testcontainers +class PostgresContainerIT extends Specification { + + @Shared + PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer(SpockTestImages.POSTGRES_TEST_IMAGE) + .withDatabaseName("foo") + .withUsername("foo") + .withPassword("secret") + + def "waits until postgres accepts jdbc connections"() { + + given: "a jdbc connection" + HikariConfig hikariConfig = new HikariConfig() + hikariConfig.setJdbcUrl(postgreSQLContainer.jdbcUrl) + hikariConfig.setUsername("foo") + hikariConfig.setPassword("secret") + HikariDataSource ds = new HikariDataSource(hikariConfig) + + when: "querying the database" + Statement statement = ds.getConnection().createStatement() + statement.execute("SELECT 1") + ResultSet resultSet = statement.getResultSet() + resultSet.next() + + then: "result is returned" + int resultSetInt = resultSet.getInt(1) + resultSetInt == 1 + + cleanup: + ds.close() + } +} +// } diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/SharedComposeContainerIT.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/SharedComposeContainerIT.groovy new file mode 100644 index 00000000000..dc71687011f --- /dev/null +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/SharedComposeContainerIT.groovy @@ -0,0 +1,39 @@ +package org.testcontainers.spock + +import org.apache.http.client.methods.HttpGet +import org.apache.http.impl.client.HttpClientBuilder +import org.testcontainers.containers.ComposeContainer +import org.testcontainers.containers.wait.strategy.Wait +import org.testcontainers.utility.DockerImageName +import spock.lang.Shared +import spock.lang.Specification + +@Testcontainers +class SharedComposeContainerIT extends Specification { + + @Shared + ComposeContainer composeContainer = new ComposeContainer( + DockerImageName.parse("docker:25.0.5"), + new File("src/test/resources/docker-compose.yml")) + .withExposedService("whoami-1", 80, Wait.forHttp("/")) + + String host + + int port + + def setup() { + host = composeContainer.getServiceHost("whoami-1", 80) + port = composeContainer.getServicePort("whoami-1", 80) + } + + def "running compose defined container is accessible on configured port"() { + given: "a http client" + def client = HttpClientBuilder.create().build() + + when: "accessing web server" + def response = client.execute(new HttpGet("http://$host:$port")) + + then: "docker container is running and returns http status code 200" + response.statusLine.statusCode == 200 + } +} diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/SharedDockerComposeContainerIT.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/SharedDockerComposeContainerIT.groovy new file mode 100644 index 00000000000..600be07f9f1 --- /dev/null +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/SharedDockerComposeContainerIT.groovy @@ -0,0 +1,39 @@ +package org.testcontainers.spock + +import org.apache.http.client.methods.HttpGet +import org.apache.http.impl.client.HttpClientBuilder +import org.testcontainers.containers.DockerComposeContainer +import org.testcontainers.containers.wait.strategy.Wait +import org.testcontainers.utility.DockerImageName +import spock.lang.Shared +import spock.lang.Specification + +@Testcontainers +class SharedDockerComposeContainerIT extends Specification { + + @Shared + DockerComposeContainer composeContainer = new DockerComposeContainer( + DockerImageName.parse("docker/compose:debian-1.29.2"), + new File("src/test/resources/docker-compose.yml")) + .withExposedService("whoami_1", 80, Wait.forHttp("/")) + + String host + + int port + + def setup() { + host = composeContainer.getServiceHost("whoami_1", 80) + port = composeContainer.getServicePort("whoami_1", 80) + } + + def "running compose defined container is accessible on configured port"() { + given: "a http client" + def client = HttpClientBuilder.create().build() + + when: "accessing web server" + def response = client.execute(new HttpGet("http://$host:$port")) + + then: "docker container is running and returns http status code 200" + response.statusLine.statusCode == 200 + } +} diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/SpockTestImages.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/SpockTestImages.groovy new file mode 100644 index 00000000000..ce16e963548 --- /dev/null +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/SpockTestImages.groovy @@ -0,0 +1,10 @@ +package org.testcontainers.spock + +import org.testcontainers.utility.DockerImageName + +interface SpockTestImages { + DockerImageName MYSQL_IMAGE = DockerImageName.parse("mysql:8.0.36") + DockerImageName POSTGRES_TEST_IMAGE = DockerImageName.parse("postgres:9.6.12") + DockerImageName HTTPD_IMAGE = DockerImageName.parse("httpd:2.4-alpine") + DockerImageName TINY_IMAGE = DockerImageName.parse("alpine:3.17") +} diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/TestHierarchyIT.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/TestHierarchyIT.groovy new file mode 100644 index 00000000000..567998281a7 --- /dev/null +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/TestHierarchyIT.groovy @@ -0,0 +1,23 @@ +package org.testcontainers.spock + +import org.testcontainers.containers.PostgreSQLContainer +import spock.lang.Shared + +/** + * This test verifies that integration tests can subclass each other. + * Also verifies that the @Testcontainers annotation is inherited. + */ +class TestHierarchyIT extends MySqlContainerIT { + + @Shared + PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer(SpockTestImages.POSTGRES_TEST_IMAGE) + .withDatabaseName("foo") + .withUsername("foo") + .withPassword("secret") + + def "both containers are running"() { + expect: + postgreSQLContainer.isRunning() + mySQLContainer.isRunning() + } +} diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/TestLifecycleAwareContainerMock.java b/modules/spock/src/test/groovy/org/testcontainers/spock/TestLifecycleAwareContainerMock.java new file mode 100644 index 00000000000..7502d097008 --- /dev/null +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/TestLifecycleAwareContainerMock.java @@ -0,0 +1,50 @@ +package org.testcontainers.spock; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.lifecycle.TestDescription; +import org.testcontainers.lifecycle.TestLifecycleAware; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class TestLifecycleAwareContainerMock + extends GenericContainer + implements TestLifecycleAware { + + static final String BEFORE_TEST = "beforeTest"; + + static final String AFTER_TEST = "afterTest"; + + final List lifecycleMethodCalls = new ArrayList<>(); + + final List lifecycleFilesystemFriendlyNames = new ArrayList<>(); + + Throwable capturedThrowable; + + public TestLifecycleAwareContainerMock() { + super(SpockTestImages.TINY_IMAGE); + } + + @Override + public void beforeTest(TestDescription description) { + lifecycleMethodCalls.add(BEFORE_TEST); + lifecycleFilesystemFriendlyNames.add(description.getFilesystemFriendlyName()); + } + + @Override + public void afterTest(TestDescription description, Optional throwable) { + lifecycleMethodCalls.add(AFTER_TEST); + throwable.ifPresent(capturedThrowable -> this.capturedThrowable = capturedThrowable); + } + + @Override + public void start() { + // Do nothing + } + + @Override + public void stop() { + // Do nothing + } +} diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/TestLifecycleAwareIT.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/TestLifecycleAwareIT.groovy new file mode 100644 index 00000000000..fae834ea422 --- /dev/null +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/TestLifecycleAwareIT.groovy @@ -0,0 +1,54 @@ +package org.testcontainers.spock + +import org.intellij.lang.annotations.Language +import spock.lang.Specification +import spock.lang.Unroll +import spock.util.EmbeddedSpecRunner + +class TestLifecycleAwareIT extends Specification { + + @Unroll("When failing test is #fails, afterTest receives '#filesystemFriendlyNames' and throwable starting with message '#errorMessageStartsWith'") + def "lifecycle awareness"() { + given: + + @Language("groovy") + String myTest = """ +import org.testcontainers.spock.Testcontainers +import org.testcontainers.containers.GenericContainer +import spock.lang.Specification + +@Testcontainers +class TestLifecycleAwareIT extends Specification { + + GenericContainer container = System.properties["org.testcontainers.container"] as GenericContainer + + def "perform test"() { + expect: + !System.properties["org.testcontainers.shouldFail"] + } +} +""" + and: + def container = new TestLifecycleAwareContainerMock() + System.properties["org.testcontainers.container"] = container + System.properties["org.testcontainers.shouldFail"] = fails + + when: "executing the test" + def runner = new EmbeddedSpecRunner(throwFailure: false) + runner.run(myTest) + + then: "mock container received lifecycle calls as expected" + container.lifecycleMethodCalls == ["beforeTest", "afterTest"] + container.lifecycleFilesystemFriendlyNames.join(",") == filesystemFriendlyNames + if (errorMessageStartsWith) { + assert container.capturedThrowable.message.startsWith(errorMessageStartsWith) + } else { + assert container.capturedThrowable == null + } + + where: + fails | filesystemFriendlyNames | errorMessageStartsWith + false | 'TestLifecycleAwareIT-perform+test' | null + true | 'TestLifecycleAwareIT-perform+test' | "Condition not satisfied:" + } +} diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/TestcontainersExtensionTest.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/TestcontainersExtensionTest.groovy new file mode 100644 index 00000000000..d8cbdf2e497 --- /dev/null +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/TestcontainersExtensionTest.groovy @@ -0,0 +1,39 @@ +package org.testcontainers.spock + +import org.spockframework.runtime.model.SpecInfo +import spock.lang.Specification +import spock.lang.Unroll + +class TestcontainersExtensionTest extends Specification { + + @Unroll + def "should handle disabledWithoutDocker=#disabledWithoutDocker and dockerAvailable=#dockerAvailable correctly"() { + given: + def dockerDetector = Mock(DockerAvailableDetector) + dockerDetector.isDockerAvailable() >> dockerAvailable + def extension = new TestcontainersExtension(dockerDetector) + def specInfo = Mock(SpecInfo) + def annotation = disabledWithoutDocker ? + TestDisabledWithoutDocker.getAnnotation(Testcontainers) : + TestEnabledWithoutDocker.getAnnotation(Testcontainers) + + when: + extension.visitSpecAnnotation(annotation, specInfo) + + then: + skipCalls * specInfo.skip("disabledWithoutDocker is true and Docker is not available") + + where: + disabledWithoutDocker | dockerAvailable | skipCalls + true | true | 0 + true | false | 1 + false | true | 0 + false | false | 0 + } + + @Testcontainers(disabledWithoutDocker = true) + static class TestDisabledWithoutDocker {} + + @Testcontainers + static class TestEnabledWithoutDocker {} +} diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/TestcontainersRestartBetweenTestsIT.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/TestcontainersRestartBetweenTestsIT.groovy new file mode 100644 index 00000000000..ebbb1de2474 --- /dev/null +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/TestcontainersRestartBetweenTestsIT.groovy @@ -0,0 +1,30 @@ +package org.testcontainers.spock + +import org.testcontainers.containers.GenericContainer +import spock.lang.Shared +import spock.lang.Specification +import spock.lang.Stepwise + +@Stepwise +@Testcontainers +class TestcontainersRestartBetweenTestsIT extends Specification { + + GenericContainer genericContainer = new GenericContainer(SpockTestImages.HTTPD_IMAGE) + .withExposedPorts(80) + + @Shared + String lastContainerId + + def "retrieving first id"() { + when: + lastContainerId = genericContainer.containerId + + then: + true + } + + def "containers is recreated between tests"() { + expect: + genericContainer.containerId != lastContainerId + } +} diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/TestcontainersSharedContainerIT.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/TestcontainersSharedContainerIT.groovy new file mode 100644 index 00000000000..dd2f8443772 --- /dev/null +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/TestcontainersSharedContainerIT.groovy @@ -0,0 +1,46 @@ +package org.testcontainers.spock + +import org.apache.http.client.methods.CloseableHttpResponse +import org.apache.http.client.methods.HttpGet +import org.apache.http.impl.client.CloseableHttpClient +import org.apache.http.impl.client.HttpClientBuilder +import org.testcontainers.containers.GenericContainer +import spock.lang.Shared +import spock.lang.Specification +import spock.lang.Stepwise + +@Stepwise +@Testcontainers +class TestcontainersSharedContainerIT extends Specification { + + @Shared + GenericContainer genericContainer = new GenericContainer(SpockTestImages.HTTPD_IMAGE) + .withExposedPorts(80) + + @Shared + String lastContainerId + + def "starts accessible docker container"() { + given: "a http client" + def client = HttpClientBuilder.create().build() + lastContainerId = genericContainer.containerId + + when: "accessing web server" + CloseableHttpResponse response = performHttpRequest(client) + + then: "docker container is running and returns http status code 200" + response.statusLine.statusCode == 200 + } + + def "containers keeps on running between features"() { + expect: + genericContainer.containerId == lastContainerId + } + + private CloseableHttpResponse performHttpRequest(CloseableHttpClient client) { + String ip = genericContainer.host + String port = genericContainer.getMappedPort(80) + def response = client.execute(new HttpGet("http://$ip:$port")) + response + } +} diff --git a/modules/spock/src/test/resources/docker-compose.yml b/modules/spock/src/test/resources/docker-compose.yml new file mode 100644 index 00000000000..810c7c178b5 --- /dev/null +++ b/modules/spock/src/test/resources/docker-compose.yml @@ -0,0 +1,4 @@ +version: '2.1' +services: + whoami: + image: emilevauge/whoami diff --git a/modules/spock/src/test/resources/logback-test.xml b/modules/spock/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/spock/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/tidb/build.gradle b/modules/tidb/build.gradle new file mode 100644 index 00000000000..a59b1f6a84e --- /dev/null +++ b/modules/tidb/build.gradle @@ -0,0 +1,9 @@ +description = "Testcontainers :: JDBC :: TiDB" + +dependencies { + api project(':testcontainers-jdbc') + + testImplementation project(':testcontainers-jdbc-test') + testRuntimeOnly 'com.mysql:mysql-connector-j:9.6.0' + compileOnly 'org.jetbrains:annotations:26.1.0' +} diff --git a/modules/tidb/sql/init_mysql.sql b/modules/tidb/sql/init_mysql.sql new file mode 100644 index 00000000000..2b00ee968b0 --- /dev/null +++ b/modules/tidb/sql/init_mysql.sql @@ -0,0 +1,5 @@ +CREATE TABLE bar ( + foo VARCHAR(255) +); + +INSERT INTO bar (foo) VALUES ('hello world'); \ No newline at end of file diff --git a/modules/tidb/src/main/java/org/testcontainers/tidb/TiDBContainer.java b/modules/tidb/src/main/java/org/testcontainers/tidb/TiDBContainer.java new file mode 100644 index 00000000000..893b2e538db --- /dev/null +++ b/modules/tidb/src/main/java/org/testcontainers/tidb/TiDBContainer.java @@ -0,0 +1,136 @@ +package org.testcontainers.tidb; + +import org.jetbrains.annotations.NotNull; +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.Set; + +/** + * Testcontainers implementation for TiDB. + *

+ * Supported image: {@code pingcap/tidb} + *

+ * Exposed ports: + *

    + *
  • Database: 4000
  • + *
  • HTTP: 10080
  • + *
+ */ +public class TiDBContainer extends JdbcDatabaseContainer { + + static final String NAME = "tidb"; + + static final String DOCKER_IMAGE_NAME = "pingcap/tidb"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse(DOCKER_IMAGE_NAME); + + private static final Integer TIDB_PORT = 4000; + + private static final int REST_API_PORT = 10080; + + private String databaseName = "test"; + + private String username = "root"; + + private String password = ""; + + public TiDBContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public TiDBContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + addExposedPorts(TIDB_PORT, REST_API_PORT); + + waitingFor( + new HttpWaitStrategy() + .forPath("/status") + .forPort(REST_API_PORT) + .forStatusCode(200) + .withStartupTimeout(Duration.ofMinutes(1)) + ); + } + + /** + * @return the ports on which to check if the container is ready + * @deprecated use {@link #getLivenessCheckPortNumbers()} instead + */ + @NotNull + @Override + @Deprecated + protected Set getLivenessCheckPorts() { + return super.getLivenessCheckPorts(); + } + + @Override + public String getDriverClassName() { + try { + Class.forName("com.mysql.cj.jdbc.Driver"); + return "com.mysql.cj.jdbc.Driver"; + } catch (ClassNotFoundException e) { + return "com.mysql.jdbc.Driver"; + } + } + + @Override + public String getJdbcUrl() { + String additionalUrlParams = constructUrlParameters("?", "&"); + return "jdbc:mysql://" + getHost() + ":" + getMappedPort(TIDB_PORT) + "/" + databaseName + additionalUrlParams; + } + + @Override + protected String constructUrlForConnection(String queryString) { + String url = super.constructUrlForConnection(queryString); + + if (!url.contains("useSSL=")) { + String separator = url.contains("?") ? "&" : "?"; + url = url + separator + "useSSL=false"; + } + + if (!url.contains("allowPublicKeyRetrieval=")) { + url = url + "&allowPublicKeyRetrieval=true"; + } + + return url; + } + + @Override + public String getDatabaseName() { + return databaseName; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getTestQueryString() { + return "SELECT 1"; + } + + @Override + public TiDBContainer withDatabaseName(final String databaseName) { + throw new UnsupportedOperationException("The TiDB docker image does not currently support this"); + } + + @Override + public TiDBContainer withUsername(final String username) { + throw new UnsupportedOperationException("The TiDB docker image does not currently support this"); + } + + @Override + public TiDBContainer withPassword(final String password) { + throw new UnsupportedOperationException("The TiDB docker image does not currently support this"); + } +} diff --git a/modules/tidb/src/main/java/org/testcontainers/tidb/TiDBContainerProvider.java b/modules/tidb/src/main/java/org/testcontainers/tidb/TiDBContainerProvider.java new file mode 100644 index 00000000000..ee66771b42c --- /dev/null +++ b/modules/tidb/src/main/java/org/testcontainers/tidb/TiDBContainerProvider.java @@ -0,0 +1,32 @@ +package org.testcontainers.tidb; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.JdbcDatabaseContainerProvider; +import org.testcontainers.utility.DockerImageName; + +/** + * Factory for TiDB containers. + */ +public class TiDBContainerProvider extends JdbcDatabaseContainerProvider { + + private static final String DEFAULT_TAG = "v6.1.0"; + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(TiDBContainer.NAME); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(DEFAULT_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + if (tag != null) { + return new TiDBContainer(DockerImageName.parse(TiDBContainer.DOCKER_IMAGE_NAME).withTag(tag)); + } else { + return newInstance(); + } + } +} diff --git a/modules/tidb/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/tidb/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 00000000000..a4168b00e74 --- /dev/null +++ b/modules/tidb/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.tidb.TiDBContainerProvider diff --git a/modules/tidb/src/test/java/org/testcontainers/TiDBTestImages.java b/modules/tidb/src/test/java/org/testcontainers/TiDBTestImages.java new file mode 100644 index 00000000000..32fb57911a2 --- /dev/null +++ b/modules/tidb/src/test/java/org/testcontainers/TiDBTestImages.java @@ -0,0 +1,8 @@ +package org.testcontainers; + +import org.testcontainers.utility.DockerImageName; + +public class TiDBTestImages { + + public static final DockerImageName TIDB_IMAGE = DockerImageName.parse("pingcap/tidb:v6.1.0"); +} diff --git a/modules/tidb/src/test/java/org/testcontainers/jdbc/tidb/TiDBJDBCDriverTest.java b/modules/tidb/src/test/java/org/testcontainers/jdbc/tidb/TiDBJDBCDriverTest.java new file mode 100644 index 00000000000..4b3d60a013c --- /dev/null +++ b/modules/tidb/src/test/java/org/testcontainers/jdbc/tidb/TiDBJDBCDriverTest.java @@ -0,0 +1,15 @@ +package org.testcontainers.jdbc.tidb; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +public class TiDBJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { { "jdbc:tc:tidb://hostname/databasename", EnumSet.noneOf(Options.class) } } + ); + } +} diff --git a/modules/tidb/src/test/java/org/testcontainers/tidb/TiDBContainerTest.java b/modules/tidb/src/test/java/org/testcontainers/tidb/TiDBContainerTest.java new file mode 100644 index 00000000000..54ded93e69e --- /dev/null +++ b/modules/tidb/src/test/java/org/testcontainers/tidb/TiDBContainerTest.java @@ -0,0 +1,66 @@ +package org.testcontainers.tidb; + +import org.junit.jupiter.api.Test; +import org.testcontainers.TiDBTestImages; +import org.testcontainers.db.AbstractContainerDatabaseTest; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +class TiDBContainerTest extends AbstractContainerDatabaseTest { + + @Test + void testSimple() throws SQLException { + try ( // container { + TiDBContainer tidb = new TiDBContainer("pingcap/tidb:v6.1.0") + // } + ) { + tidb.start(); + + ResultSet resultSet = performQuery(tidb, "SELECT 1"); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).isEqualTo(1); + assertHasCorrectExposedAndLivenessCheckPorts(tidb); + } + } + + @Test + void testExplicitInitScript() throws SQLException { + try ( + TiDBContainer tidb = new TiDBContainer(TiDBTestImages.TIDB_IMAGE).withInitScript("somepath/init_tidb.sql") + ) { // TiDB is expected to be compatible with MySQL + tidb.start(); + + ResultSet resultSet = performQuery(tidb, "SELECT foo FROM bar"); + + String firstColumnValue = resultSet.getString(1); + assertThat(firstColumnValue).isEqualTo("hello world"); + } + } + + @Test + void testWithAdditionalUrlParamInJdbcUrl() { + TiDBContainer tidb = new TiDBContainer(TiDBTestImages.TIDB_IMAGE).withUrlParam("sslmode", "disable"); + + try { + tidb.start(); + String jdbcUrl = tidb.getJdbcUrl(); + assertThat(jdbcUrl).contains("?"); + assertThat(jdbcUrl).contains("sslmode=disable"); + } finally { + tidb.stop(); + } + } + + private void assertHasCorrectExposedAndLivenessCheckPorts(TiDBContainer tidb) { + Integer tidbPort = 4000; + Integer restApiPort = 10080; + + assertThat(tidb.getExposedPorts()).containsExactlyInAnyOrder(tidbPort, restApiPort); + assertThat(tidb.getLivenessCheckPortNumbers()) + .containsExactlyInAnyOrder(tidb.getMappedPort(tidbPort), tidb.getMappedPort(restApiPort)); + } +} diff --git a/modules/tidb/src/test/resources/logback-test.xml b/modules/tidb/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/tidb/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/tidb/src/test/resources/somepath/init_tidb.sql b/modules/tidb/src/test/resources/somepath/init_tidb.sql new file mode 100644 index 00000000000..73bc09151cb --- /dev/null +++ b/modules/tidb/src/test/resources/somepath/init_tidb.sql @@ -0,0 +1,16 @@ +CREATE TABLE bar ( + foo VARCHAR(255) +); + +SELECT "a /* string literal containing comment characters like -- here"; +SELECT "a 'quoting' \"scenario ` involving BEGIN keyword\" here"; +SELECT * from `bar`; + +-- What about a line comment containing imbalanced string delimiters? " + +/* or a block comment + containing imbalanced string delimiters? + ' " + */ + +INSERT INTO bar (foo) /* ; */ VALUES ('hello world'); diff --git a/modules/timeplus/build.gradle b/modules/timeplus/build.gradle new file mode 100644 index 00000000000..8acf35ef232 --- /dev/null +++ b/modules/timeplus/build.gradle @@ -0,0 +1,9 @@ +description = "Testcontainers :: JDBC :: Timeplus" + +dependencies { + api project(':testcontainers') + api project(':testcontainers-jdbc') + + testImplementation project(':testcontainers-jdbc-test') + testRuntimeOnly 'com.timeplus:timeplus-native-jdbc:2.0.10' +} diff --git a/modules/timeplus/src/main/java/org/testcontainers/timeplus/TimeplusContainer.java b/modules/timeplus/src/main/java/org/testcontainers/timeplus/TimeplusContainer.java new file mode 100644 index 00000000000..0ba28c8f10a --- /dev/null +++ b/modules/timeplus/src/main/java/org/testcontainers/timeplus/TimeplusContainer.java @@ -0,0 +1,125 @@ +package org.testcontainers.timeplus; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.HashSet; +import java.util.Set; + +/** + * Testcontainers implementation for Timeplus. + *

+ * Supported image: {@code timeplus/timeplusd} + *

+ * Exposed ports: + *

    + *
  • Database: 8463
  • + *
  • HTTP: 3218
  • + *
+ */ +public class TimeplusContainer extends JdbcDatabaseContainer { + + static final String NAME = "timeplus"; + + static final String DOCKER_IMAGE_NAME = "timeplus/timeplusd"; + + private static final DockerImageName TIMEPLUS_IMAGE_NAME = DockerImageName.parse(DOCKER_IMAGE_NAME); + + private static final Integer HTTP_PORT = 3218; + + private static final Integer NATIVE_PORT = 8463; + + private static final String DRIVER_CLASS_NAME = "com.timeplus.jdbc.TimeplusDriver"; + + private static final String JDBC_URL_PREFIX = "jdbc:" + NAME + "://"; + + private static final String TEST_QUERY = "SELECT 1"; + + private String databaseName = "default"; + + private String username = "default"; + + private String password = ""; + + public TimeplusContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public TimeplusContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(TIMEPLUS_IMAGE_NAME); + + addExposedPorts(HTTP_PORT, NATIVE_PORT); + waitingFor(Wait.forHttp("/timeplusd/v1/ping").forStatusCode(200).withStartupTimeout(Duration.ofMinutes(1))); + } + + @Override + protected void configure() { + withEnv("TIMEPLUS_DB", this.databaseName); + withEnv("TIMEPLUS_USER", this.username); + withEnv("TIMEPLUS_PASSWORD", this.password); + } + + @Override + public Set getLivenessCheckPortNumbers() { + return new HashSet<>(getMappedPort(HTTP_PORT)); + } + + @Override + public String getDriverClassName() { + return DRIVER_CLASS_NAME; + } + + @Override + public String getJdbcUrl() { + return ( + JDBC_URL_PREFIX + + getHost() + + ":" + + getMappedPort(NATIVE_PORT) + + "/" + + this.databaseName + + constructUrlParameters("?", "&") + ); + } + + @Override + public String getUsername() { + return this.username; + } + + @Override + public String getPassword() { + return this.password; + } + + @Override + public String getDatabaseName() { + return this.databaseName; + } + + @Override + public String getTestQueryString() { + return TEST_QUERY; + } + + @Override + public TimeplusContainer withUsername(String username) { + this.username = username; + return this; + } + + @Override + public TimeplusContainer withPassword(String password) { + this.password = password; + return this; + } + + @Override + public TimeplusContainer withDatabaseName(String databaseName) { + this.databaseName = databaseName; + return this; + } +} diff --git a/modules/timeplus/src/main/java/org/testcontainers/timeplus/TimeplusContainerProvider.java b/modules/timeplus/src/main/java/org/testcontainers/timeplus/TimeplusContainerProvider.java new file mode 100644 index 00000000000..1d71e654a45 --- /dev/null +++ b/modules/timeplus/src/main/java/org/testcontainers/timeplus/TimeplusContainerProvider.java @@ -0,0 +1,32 @@ +package org.testcontainers.timeplus; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.JdbcDatabaseContainerProvider; +import org.testcontainers.utility.DockerImageName; + +/** + * Factory for Timeplus containers. + */ +public class TimeplusContainerProvider extends JdbcDatabaseContainerProvider { + + private static final String DEFAULT_TAG = "2.3.21"; + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(TimeplusContainer.NAME); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(DEFAULT_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + if (tag != null) { + return new TimeplusContainer(DockerImageName.parse(TimeplusContainer.DOCKER_IMAGE_NAME).withTag(tag)); + } else { + return newInstance(); + } + } +} diff --git a/modules/timeplus/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/timeplus/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 00000000000..f3122d3d73b --- /dev/null +++ b/modules/timeplus/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.timeplus.TimeplusContainerProvider diff --git a/modules/timeplus/src/test/java/org/testcontainers/TimeplusImages.java b/modules/timeplus/src/test/java/org/testcontainers/TimeplusImages.java new file mode 100644 index 00000000000..4079e0b4524 --- /dev/null +++ b/modules/timeplus/src/test/java/org/testcontainers/TimeplusImages.java @@ -0,0 +1,7 @@ +package org.testcontainers; + +import org.testcontainers.utility.DockerImageName; + +public interface TimeplusImages { + DockerImageName TIMEPLUS_IMAGE = DockerImageName.parse("timeplus/timeplusd:2.3.21"); +} diff --git a/modules/timeplus/src/test/java/org/testcontainers/junit/timeplus/TimeplusJDBCDriverTest.java b/modules/timeplus/src/test/java/org/testcontainers/junit/timeplus/TimeplusJDBCDriverTest.java new file mode 100644 index 00000000000..1427f33e5a4 --- /dev/null +++ b/modules/timeplus/src/test/java/org/testcontainers/junit/timeplus/TimeplusJDBCDriverTest.java @@ -0,0 +1,15 @@ +package org.testcontainers.junit.timeplus; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +class TimeplusJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { { "jdbc:tc:timeplus:2.3.21://hostname", EnumSet.noneOf(Options.class) } } + ); + } +} diff --git a/modules/timeplus/src/test/java/org/testcontainers/timeplus/TimeplusContainerTest.java b/modules/timeplus/src/test/java/org/testcontainers/timeplus/TimeplusContainerTest.java new file mode 100644 index 00000000000..7ab38c03f87 --- /dev/null +++ b/modules/timeplus/src/test/java/org/testcontainers/timeplus/TimeplusContainerTest.java @@ -0,0 +1,49 @@ +package org.testcontainers.timeplus; + +import org.junit.jupiter.api.Test; +import org.testcontainers.TimeplusImages; +import org.testcontainers.db.AbstractContainerDatabaseTest; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +class TimeplusContainerTest extends AbstractContainerDatabaseTest { + + @Test + void testSimple() throws SQLException { + try ( // container { + TimeplusContainer timeplus = new TimeplusContainer("timeplus/timeplusd:2.3.21") + // } + ) { + timeplus.start(); + + ResultSet resultSet = performQuery(timeplus, "SELECT 1"); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).isEqualTo(1); + } + } + + @Test + void customCredentialsWithUrlParams() throws SQLException { + try ( + TimeplusContainer timeplus = new TimeplusContainer(TimeplusImages.TIMEPLUS_IMAGE) + .withUsername("system") + .withPassword("sys@t+") + .withDatabaseName("system") + .withUrlParam("interactive_delay", "5") + ) { + timeplus.start(); + + ResultSet resultSet = performQuery( + timeplus, + "SELECT to_int(value) FROM system.settings where name='interactive_delay'" + ); + + int resultSetInt = resultSet.getInt(1); + assertThat(resultSetInt).isEqualTo(5); + } + } +} diff --git a/modules/timeplus/src/test/resources/logback-test.xml b/modules/timeplus/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/timeplus/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/toxiproxy/build.gradle b/modules/toxiproxy/build.gradle new file mode 100644 index 00000000000..3e3a63a1fca --- /dev/null +++ b/modules/toxiproxy/build.gradle @@ -0,0 +1,8 @@ +description = "Testcontainers :: Toxiproxy" + +dependencies { + api project(':testcontainers') + api 'eu.rekawek.toxiproxy:toxiproxy-java:2.1.11' + + testImplementation 'redis.clients:jedis:7.5.3' +} diff --git a/modules/toxiproxy/src/main/java/org/testcontainers/containers/ToxiproxyContainer.java b/modules/toxiproxy/src/main/java/org/testcontainers/containers/ToxiproxyContainer.java new file mode 100644 index 00000000000..a3f12516f8d --- /dev/null +++ b/modules/toxiproxy/src/main/java/org/testcontainers/containers/ToxiproxyContainer.java @@ -0,0 +1,204 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import eu.rekawek.toxiproxy.Proxy; +import eu.rekawek.toxiproxy.ToxiproxyClient; +import eu.rekawek.toxiproxy.model.ToxicDirection; +import eu.rekawek.toxiproxy.model.ToxicList; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Testcontainers implementation for Toxiproxy. + *

+ * Supported images: {@code ghcr.io/shopify/toxiproxy}, {@code shopify/toxiproxy} + *

+ * Exposed ports: + *

    + *
  • HTTP: 8474
  • + *
  • Proxied Ports: 8666-8697
  • + *
+ * + * @deprecated use {@link org.testcontainers.toxiproxy.ToxiproxyContainer} instead. + */ +@Deprecated +public class ToxiproxyContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("shopify/toxiproxy"); + + private static final String DEFAULT_TAG = "2.1.0"; + + private static final DockerImageName GHCR_IMAGE_NAME = DockerImageName.parse("ghcr.io/shopify/toxiproxy"); + + private static final int TOXIPROXY_CONTROL_PORT = 8474; + + private static final int FIRST_PROXIED_PORT = 8666; + + private static final int LAST_PROXIED_PORT = 8666 + 31; + + private ToxiproxyClient client; + + private final Map proxies = new HashMap<>(); + + private final AtomicInteger nextPort = new AtomicInteger(FIRST_PROXIED_PORT); + + /** + * @deprecated use {@link #ToxiproxyContainer(DockerImageName)} instead + */ + @Deprecated + public ToxiproxyContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + public ToxiproxyContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public ToxiproxyContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, GHCR_IMAGE_NAME); + + addExposedPorts(TOXIPROXY_CONTROL_PORT); + setWaitStrategy(new HttpWaitStrategy().forPath("/version").forPort(TOXIPROXY_CONTROL_PORT)); + + // allow up to 32 ports to be proxied (arbitrary value). Here we make the ports exposed; whether or not + // Toxiproxy will listen is controlled at runtime using getProxy(...) + for (int i = FIRST_PROXIED_PORT; i <= LAST_PROXIED_PORT; i++) { + addExposedPort(i); + } + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + client = new ToxiproxyClient(getHost(), getMappedPort(TOXIPROXY_CONTROL_PORT)); + } + + /** + * @return Publicly exposed Toxiproxy HTTP API control port. + */ + public int getControlPort() { + return getMappedPort(TOXIPROXY_CONTROL_PORT); + } + + /** + * Obtain a {@link ContainerProxy} instance for target container that is managed by Testcontainers. The target + * container should be routable from this {@link ToxiproxyContainer} instance (e.g. on the same + * Docker {@link Network}). + * + * @param container target container + * @param port port number on the target service that should be proxied + * @return a {@link ContainerProxy} instance + * @deprecated {@link ToxiproxyContainer} will not build the client. Proxies should be provided manually. + */ + @Deprecated + public ContainerProxy getProxy(GenericContainer container, int port) { + return this.getProxy(container.getNetworkAliases().get(0), port); + } + + /** + * Obtain a {@link ContainerProxy} instance for a specific hostname and port, which can be for any host + * that is routable from this {@link ToxiproxyContainer} instance (e.g. on the same + * Docker {@link Network} or on routable from the Docker host). + * + *

It is expected that {@link ToxiproxyContainer#getProxy(GenericContainer, int)} will be more + * useful in most scenarios, but this method is present to allow use of Toxiproxy in front of containers + * or external servers that are not managed by Testcontainers.

+ * + * @param hostname hostname of target server to be proxied + * @param port port number on the target server that should be proxied + * @return a {@link ContainerProxy} instance + * @deprecated {@link ToxiproxyContainer} will not build the client. Proxies should be provided manually. + */ + @Deprecated + public ContainerProxy getProxy(String hostname, int port) { + String upstream = hostname + ":" + port; + + return proxies.computeIfAbsent( + upstream, + __ -> { + try { + final int toxiPort = nextPort.getAndIncrement(); + if (toxiPort > LAST_PROXIED_PORT) { + throw new IllegalStateException("Maximum number of proxies exceeded"); + } + + final Proxy proxy = client.createProxy(upstream, "0.0.0.0:" + toxiPort, upstream); + final int mappedPort = getMappedPort(toxiPort); + return new ContainerProxy(proxy, getHost(), mappedPort, toxiPort); + } catch (IOException e) { + throw new RuntimeException("Proxy could not be created", e); + } + } + ); + } + + @RequiredArgsConstructor(access = AccessLevel.PROTECTED) + @Deprecated + public static class ContainerProxy { + + private static final String CUT_CONNECTION_DOWNSTREAM = "CUT_CONNECTION_DOWNSTREAM"; + + private static final String CUT_CONNECTION_UPSTREAM = "CUT_CONNECTION_UPSTREAM"; + + private final Proxy toxi; + + /** + * The IP address that this proxy container may be reached on from the host machine. + */ + @Getter + private final String containerIpAddress; + + /** + * The mapped port of this proxy. This is a port of the host machine. It can be used to + * access the Toxiproxy container from the host machine. + */ + @Getter + private final int proxyPort; + + /** + * The original (exposed) port of this proxy. This is a port of the Toxiproxy Docker + * container. It can be used to access this container from a different Docker container + * on the same network. + */ + @Getter + private final int originalProxyPort; + + private boolean isCurrentlyCut; + + public String getName() { + return toxi.getName(); + } + + public ToxicList toxics() { + return toxi.toxics(); + } + + /** + * Cuts the connection by setting bandwidth in both directions to zero. + * @param shouldCutConnection true if the connection should be cut, or false if it should be re-enabled + */ + public void setConnectionCut(boolean shouldCutConnection) { + try { + if (shouldCutConnection) { + toxics().bandwidth(CUT_CONNECTION_DOWNSTREAM, ToxicDirection.DOWNSTREAM, 0); + toxics().bandwidth(CUT_CONNECTION_UPSTREAM, ToxicDirection.UPSTREAM, 0); + isCurrentlyCut = true; + } else if (isCurrentlyCut) { + toxics().get(CUT_CONNECTION_DOWNSTREAM).remove(); + toxics().get(CUT_CONNECTION_UPSTREAM).remove(); + isCurrentlyCut = false; + } + } catch (IOException e) { + throw new RuntimeException("Could not control proxy", e); + } + } + } +} diff --git a/modules/toxiproxy/src/main/java/org/testcontainers/toxiproxy/ToxiproxyContainer.java b/modules/toxiproxy/src/main/java/org/testcontainers/toxiproxy/ToxiproxyContainer.java new file mode 100644 index 00000000000..b4ab7e29c96 --- /dev/null +++ b/modules/toxiproxy/src/main/java/org/testcontainers/toxiproxy/ToxiproxyContainer.java @@ -0,0 +1,54 @@ +package org.testcontainers.toxiproxy; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for Toxiproxy. + *

+ * Supported images: {@code ghcr.io/shopify/toxiproxy}, {@code shopify/toxiproxy} + *

+ * Exposed ports: + *

    + *
  • HTTP: 8474
  • + *
  • Proxied Ports: 8666-8697
  • + *
+ */ +public class ToxiproxyContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("shopify/toxiproxy"); + + private static final DockerImageName GHCR_IMAGE_NAME = DockerImageName.parse("ghcr.io/shopify/toxiproxy"); + + private static final int TOXIPROXY_CONTROL_PORT = 8474; + + private static final int FIRST_PROXIED_PORT = 8666; + + private static final int LAST_PROXIED_PORT = 8666 + 31; + + public ToxiproxyContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public ToxiproxyContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, GHCR_IMAGE_NAME); + + addExposedPorts(TOXIPROXY_CONTROL_PORT); + setWaitStrategy(new HttpWaitStrategy().forPath("/version").forPort(TOXIPROXY_CONTROL_PORT)); + + // allow up to 32 ports to be proxied (arbitrary value). Here we make the ports exposed; whether or not + // Toxiproxy will listen is controlled at runtime using getProxy(...) + for (int i = FIRST_PROXIED_PORT; i <= LAST_PROXIED_PORT; i++) { + addExposedPort(i); + } + } + + /** + * @return Publicly exposed Toxiproxy HTTP API control port. + */ + public int getControlPort() { + return getMappedPort(TOXIPROXY_CONTROL_PORT); + } +} diff --git a/modules/toxiproxy/src/test/java/org/testcontainers/toxiproxy/ToxiproxyContainerTest.java b/modules/toxiproxy/src/test/java/org/testcontainers/toxiproxy/ToxiproxyContainerTest.java new file mode 100644 index 00000000000..2247f135f7b --- /dev/null +++ b/modules/toxiproxy/src/test/java/org/testcontainers/toxiproxy/ToxiproxyContainerTest.java @@ -0,0 +1,185 @@ +package org.testcontainers.toxiproxy; + +import eu.rekawek.toxiproxy.Proxy; +import eu.rekawek.toxiproxy.ToxiproxyClient; +import eu.rekawek.toxiproxy.model.ToxicDirection; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.exceptions.JedisConnectionException; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +public class ToxiproxyContainerTest { + + private static final Duration JEDIS_TIMEOUT = Duration.ofSeconds(10); + + // spotless:off + // creatingProxy { + // Create a common docker network so that containers can communicate + @AutoClose + public Network network = Network.newNetwork(); + + // The target container - this could be anything + @AutoClose + public GenericContainer redis = new GenericContainer<>("redis:6-alpine") + .withExposedPorts(6379) + .withNetwork(network) + .withNetworkAliases("redis"); + + // Toxiproxy container, which will be used as a TCP proxy + @AutoClose + public ToxiproxyContainer toxiproxy = new ToxiproxyContainer("ghcr.io/shopify/toxiproxy:2.5.0") + .withNetwork(network); + // } + // spotless:on + + @BeforeEach + public void setUp() { + redis.start(); + toxiproxy.start(); + } + + @Test + public void testDirect() { + final Jedis jedis = createJedis(redis.getHost(), redis.getFirstMappedPort()); + jedis.set("somekey", "somevalue"); + + final String s = jedis.get("somekey"); + assertThat(s).as("direct access to the container works OK").isEqualTo("somevalue"); + } + + @Test + public void testLatencyViaProxy() throws IOException { + // obtainProxyObject { + final ToxiproxyClient toxiproxyClient = new ToxiproxyClient(toxiproxy.getHost(), toxiproxy.getControlPort()); + final Proxy proxy = toxiproxyClient.createProxy("redis", "0.0.0.0:8666", "redis:6379"); + // } + + // obtainProxiedHostAndPortForHostMachine { + final String ipAddressViaToxiproxy = toxiproxy.getHost(); + final int portViaToxiproxy = toxiproxy.getMappedPort(8666); + // } + + final Jedis jedis = createJedis(ipAddressViaToxiproxy, portViaToxiproxy); + jedis.set("somekey", "somevalue"); + + checkCallWithLatency(jedis, "without interference", 0, 250); + + // spotless:off + // addingLatency { + proxy.toxics() + .latency("latency", ToxicDirection.DOWNSTREAM, 1_100) + .setJitter(100); + // from now on the connection latency should be from 1000-1200 ms. + // } + // spotless:on + + checkCallWithLatency(jedis, "with interference", 1_000, 1_500); + } + + @Test + public void testConnectionCut() throws IOException { + final ToxiproxyClient toxiproxyClient = new ToxiproxyClient(toxiproxy.getHost(), toxiproxy.getControlPort()); + final Proxy proxy = toxiproxyClient.createProxy("redis", "0.0.0.0:8666", "redis:6379"); + final Jedis jedis = createJedis(toxiproxy.getHost(), toxiproxy.getMappedPort(8666)); + jedis.set("somekey", "somevalue"); + + assertThat(jedis.get("somekey")) + .as("access to the container works OK before cutting the connection") + .isEqualTo("somevalue"); + + // disableProxy { + proxy.toxics().bandwidth("CUT_CONNECTION_DOWNSTREAM", ToxicDirection.DOWNSTREAM, 0); + proxy.toxics().bandwidth("CUT_CONNECTION_UPSTREAM", ToxicDirection.UPSTREAM, 0); + + // for example, expect failure when the connection is cut + assertThat( + catchThrowable(() -> { + jedis.get("somekey"); + }) + ) + .as("calls fail when the connection is cut") + .isInstanceOf(JedisConnectionException.class); + + proxy.toxics().get("CUT_CONNECTION_DOWNSTREAM").remove(); + proxy.toxics().get("CUT_CONNECTION_UPSTREAM").remove(); + + jedis.close(); + // and with the connection re-established, expect success + assertThat(jedis.get("somekey")) + .as("access to the container works OK after re-establishing the connection") + .isEqualTo("somevalue"); + // } + } + + @Test + public void testMultipleProxiesCanBeCreated() throws IOException { + try ( + GenericContainer secondRedis = new GenericContainer<>("redis:6-alpine") + .withExposedPorts(6379) + .withNetwork(network) + .withNetworkAliases("redis2") + ) { + secondRedis.start(); + + final ToxiproxyClient toxiproxyClient = new ToxiproxyClient( + toxiproxy.getHost(), + toxiproxy.getControlPort() + ); + final Proxy firstProxy = toxiproxyClient.createProxy("redis1", "0.0.0.0:8666", "redis:6379"); + toxiproxyClient.createProxy("redis2", "0.0.0.0:8667", "redis2:6379"); + + final Jedis firstJedis = createJedis(toxiproxy.getHost(), toxiproxy.getMappedPort(8666)); + final Jedis secondJedis = createJedis(toxiproxy.getHost(), toxiproxy.getMappedPort(8667)); + + firstJedis.set("somekey", "somevalue"); + secondJedis.set("somekey", "somevalue"); + + firstProxy.toxics().bandwidth("CUT_CONNECTION_DOWNSTREAM", ToxicDirection.DOWNSTREAM, 0); + firstProxy.toxics().bandwidth("CUT_CONNECTION_UPSTREAM", ToxicDirection.UPSTREAM, 0); + + assertThat( + catchThrowable(() -> { + firstJedis.get("somekey"); + }) + ) + .as("calls fail when the connection is cut, for only the relevant proxy") + .isInstanceOf(JedisConnectionException.class); + + assertThat(secondJedis.get("somekey")).as("access via a different proxy is OK").isEqualTo("somevalue"); + } + } + + private void checkCallWithLatency( + Jedis jedis, + final String description, + int expectedMinLatency, + long expectedMaxLatency + ) { + final long start = System.nanoTime(); + String s = jedis.get("somekey"); + final long end = System.nanoTime(); + final long duration = TimeUnit.NANOSECONDS.toMillis(end - start); + + assertThat(s).as(String.format("access to the container %s works OK", description)).isEqualTo("somevalue"); + assertThat(duration >= expectedMinLatency) + .as(String.format("%s there is at least %dms latency", description, expectedMinLatency)) + .isTrue(); + assertThat(duration < expectedMaxLatency) + .as(String.format("%s there is no more than %dms latency", description, expectedMaxLatency)) + .isTrue(); + } + + private static Jedis createJedis(String host, int port) { + return new Jedis(host, port, Math.toIntExact(JEDIS_TIMEOUT.toMillis())); + } +} diff --git a/modules/toxiproxy/src/test/resources/logback-test.xml b/modules/toxiproxy/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/toxiproxy/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/trino/build.gradle b/modules/trino/build.gradle new file mode 100644 index 00000000000..12d3d05f56b --- /dev/null +++ b/modules/trino/build.gradle @@ -0,0 +1,9 @@ +description = "Testcontainers :: JDBC :: Trino" + +dependencies { + api project(':testcontainers-jdbc') + + testImplementation project(':testcontainers-jdbc-test') + testRuntimeOnly 'io.trino:trino-jdbc:483' + compileOnly 'org.jetbrains:annotations:26.1.0' +} diff --git a/modules/trino/src/main/java/org/testcontainers/containers/TrinoContainer.java b/modules/trino/src/main/java/org/testcontainers/containers/TrinoContainer.java new file mode 100644 index 00000000000..4f61b6e2352 --- /dev/null +++ b/modules/trino/src/main/java/org/testcontainers/containers/TrinoContainer.java @@ -0,0 +1,110 @@ +package org.testcontainers.containers; + +import com.google.common.base.Strings; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.VisibleForTesting; +import org.testcontainers.utility.DockerImageName; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Set; + +/** + * Testcontainers implementation for TrinoDB. + *

+ * Supported image: {@code trinodb/trino} + *

+ * Exposed ports: 8080 + * + * @deprecated use {@link org.testcontainers.trino.TrinoContainer} instead. + */ +@Deprecated +public class TrinoContainer extends JdbcDatabaseContainer { + + static final String NAME = "trino"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("trinodb/trino"); + + static final String IMAGE = "trinodb/trino"; + + @VisibleForTesting + static final String DEFAULT_TAG = "352"; + + private static final int TRINO_PORT = 8080; + + private String username = "test"; + + private String catalog = null; + + public TrinoContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public TrinoContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + addExposedPort(TRINO_PORT); + } + + /** + * @return the ports on which to check if the container is ready + * @deprecated use {@link #getLivenessCheckPortNumbers()} instead + */ + @NotNull + @Override + @Deprecated + protected Set getLivenessCheckPorts() { + return super.getLivenessCheckPorts(); + } + + @Override + public String getDriverClassName() { + return "io.trino.jdbc.TrinoDriver"; + } + + @Override + public String getJdbcUrl() { + return String.format( + "jdbc:trino://%s:%s/%s", + getHost(), + getMappedPort(TRINO_PORT), + Strings.nullToEmpty(catalog) + ); + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return ""; + } + + @Override + public String getDatabaseName() { + return catalog; + } + + @Override + public String getTestQueryString() { + return "SELECT count(*) FROM tpch.tiny.nation"; + } + + @Override + public TrinoContainer withUsername(final String username) { + this.username = username; + return this; + } + + @Override + public TrinoContainer withDatabaseName(String dbName) { + this.catalog = dbName; + return this; + } + + public Connection createConnection() throws SQLException, NoDriverFoundException { + return createConnection(""); + } +} diff --git a/modules/trino/src/main/java/org/testcontainers/containers/TrinoContainerProvider.java b/modules/trino/src/main/java/org/testcontainers/containers/TrinoContainerProvider.java new file mode 100644 index 00000000000..30d877c9e4a --- /dev/null +++ b/modules/trino/src/main/java/org/testcontainers/containers/TrinoContainerProvider.java @@ -0,0 +1,24 @@ +package org.testcontainers.containers; + +import org.testcontainers.utility.DockerImageName; + +/** + * Factory for Trino containers. + */ +public class TrinoContainerProvider extends JdbcDatabaseContainerProvider { + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(TrinoContainer.NAME); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(TrinoContainer.DEFAULT_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + return new TrinoContainer(DockerImageName.parse(TrinoContainer.IMAGE).withTag(tag)); + } +} diff --git a/modules/trino/src/main/java/org/testcontainers/trino/TrinoContainer.java b/modules/trino/src/main/java/org/testcontainers/trino/TrinoContainer.java new file mode 100644 index 00000000000..145ae4b454c --- /dev/null +++ b/modules/trino/src/main/java/org/testcontainers/trino/TrinoContainer.java @@ -0,0 +1,108 @@ +package org.testcontainers.trino; + +import com.google.common.base.Strings; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.VisibleForTesting; +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.utility.DockerImageName; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Set; + +/** + * Testcontainers implementation for TrinoDB. + *

+ * Supported image: {@code trinodb/trino} + *

+ * Exposed ports: 8080 + */ +public class TrinoContainer extends JdbcDatabaseContainer { + + static final String NAME = "trino"; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("trinodb/trino"); + + static final String IMAGE = "trinodb/trino"; + + @VisibleForTesting + static final String DEFAULT_TAG = "352"; + + private static final int TRINO_PORT = 8080; + + private String username = "test"; + + private String catalog = null; + + public TrinoContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public TrinoContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + addExposedPort(TRINO_PORT); + } + + /** + * @return the ports on which to check if the container is ready + * @deprecated use {@link #getLivenessCheckPortNumbers()} instead + */ + @NotNull + @Override + @Deprecated + protected Set getLivenessCheckPorts() { + return super.getLivenessCheckPorts(); + } + + @Override + public String getDriverClassName() { + return "io.trino.jdbc.TrinoDriver"; + } + + @Override + public String getJdbcUrl() { + return String.format( + "jdbc:trino://%s:%s/%s", + getHost(), + getMappedPort(TRINO_PORT), + Strings.nullToEmpty(catalog) + ); + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return ""; + } + + @Override + public String getDatabaseName() { + return catalog; + } + + @Override + public String getTestQueryString() { + return "SELECT count(*) FROM tpch.tiny.nation"; + } + + @Override + public TrinoContainer withUsername(final String username) { + this.username = username; + return this; + } + + @Override + public TrinoContainer withDatabaseName(String dbName) { + this.catalog = dbName; + return this; + } + + public Connection createConnection() throws SQLException, NoDriverFoundException { + return createConnection(""); + } +} diff --git a/modules/trino/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/trino/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 00000000000..637633627e5 --- /dev/null +++ b/modules/trino/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.containers.TrinoContainerProvider diff --git a/modules/trino/src/test/java/org/testcontainers/TrinoTestImages.java b/modules/trino/src/test/java/org/testcontainers/TrinoTestImages.java new file mode 100644 index 00000000000..e6770bd8993 --- /dev/null +++ b/modules/trino/src/test/java/org/testcontainers/TrinoTestImages.java @@ -0,0 +1,9 @@ +package org.testcontainers; + +import org.testcontainers.utility.DockerImageName; + +public interface TrinoTestImages { + DockerImageName TRINO_TEST_IMAGE = DockerImageName.parse("trinodb/trino:352"); + + DockerImageName TRINO_PREVIOUS_VERSION_TEST_IMAGE = DockerImageName.parse("trinodb/trino:351"); +} diff --git a/modules/trino/src/test/java/org/testcontainers/jdbc/trino/TrinoJDBCDriverTest.java b/modules/trino/src/test/java/org/testcontainers/jdbc/trino/TrinoJDBCDriverTest.java new file mode 100644 index 00000000000..7674d1bc459 --- /dev/null +++ b/modules/trino/src/test/java/org/testcontainers/jdbc/trino/TrinoJDBCDriverTest.java @@ -0,0 +1,17 @@ +package org.testcontainers.jdbc.trino; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +class TrinoJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { // + { "jdbc:tc:trino:352://hostname/", EnumSet.of(Options.PmdKnownBroken) }, + } + ); + } +} diff --git a/modules/trino/src/test/java/org/testcontainers/trino/TrinoContainerTest.java b/modules/trino/src/test/java/org/testcontainers/trino/TrinoContainerTest.java new file mode 100644 index 00000000000..c76c1bbb322 --- /dev/null +++ b/modules/trino/src/test/java/org/testcontainers/trino/TrinoContainerTest.java @@ -0,0 +1,71 @@ +package org.testcontainers.trino; + +import org.junit.jupiter.api.Test; +import org.testcontainers.TrinoTestImages; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; + +import static org.assertj.core.api.Assertions.assertThat; + +class TrinoContainerTest { + + @Test + void testSimple() throws Exception { + try ( // container { + TrinoContainer trino = new TrinoContainer("trinodb/trino:352") + // } + ) { + trino.start(); + try ( + Connection connection = trino.createConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT DISTINCT node_version FROM system.runtime.nodes") + ) { + assertThat(resultSet.next()).as("results").isTrue(); + assertThat(resultSet.getString("node_version")).as("Trino version").isEqualTo("352"); + assertContainerHasCorrectExposedAndLivenessCheckPorts(trino); + } + } + } + + @Test + void testSpecificVersion() throws Exception { + try (TrinoContainer trino = new TrinoContainer(TrinoTestImages.TRINO_PREVIOUS_VERSION_TEST_IMAGE)) { + trino.start(); + try ( + Connection connection = trino.createConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT DISTINCT node_version FROM system.runtime.nodes") + ) { + assertThat(resultSet.next()).as("results").isTrue(); + assertThat(resultSet.getString("node_version")) + .as("Trino version") + .isEqualTo(TrinoTestImages.TRINO_PREVIOUS_VERSION_TEST_IMAGE.getVersionPart()); + } + } + } + + @Test + void testInitScript() throws Exception { + try (TrinoContainer trino = new TrinoContainer(TrinoTestImages.TRINO_TEST_IMAGE)) { + trino.withInitScript("initial.sql"); + trino.start(); + try ( + Connection connection = trino.createConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT a FROM memory.default.test_table") + ) { + assertThat(resultSet.next()).as("results").isTrue(); + assertThat(resultSet.getObject("a")).as("Value").isEqualTo(12345678909324L); + assertThat(resultSet.next()).as("results").isFalse(); + } + } + } + + private void assertContainerHasCorrectExposedAndLivenessCheckPorts(TrinoContainer trino) { + assertThat(trino.getExposedPorts()).containsExactly(8080); + assertThat(trino.getLivenessCheckPortNumbers()).containsExactly(trino.getMappedPort(8080)); + } +} diff --git a/modules/trino/src/test/resources/initial.sql b/modules/trino/src/test/resources/initial.sql new file mode 100644 index 00000000000..1a3ecdab4f9 --- /dev/null +++ b/modules/trino/src/test/resources/initial.sql @@ -0,0 +1,2 @@ +CREATE TABLE memory.default.test_table(a bigint); +INSERT INTO memory.default.test_table(a) VALUES (12345678909324); diff --git a/modules/trino/src/test/resources/logback-test.xml b/modules/trino/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/trino/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/typesense/build.gradle b/modules/typesense/build.gradle new file mode 100644 index 00000000000..87aef78cad1 --- /dev/null +++ b/modules/typesense/build.gradle @@ -0,0 +1,7 @@ +description = "Testcontainers :: Typesense" + +dependencies { + api project(':testcontainers') + + testImplementation 'org.typesense:typesense-java:2.1.0' +} diff --git a/modules/typesense/src/main/java/org/testcontainers/typesense/TypesenseContainer.java b/modules/typesense/src/main/java/org/testcontainers/typesense/TypesenseContainer.java new file mode 100644 index 00000000000..f5ba92aee02 --- /dev/null +++ b/modules/typesense/src/main/java/org/testcontainers/typesense/TypesenseContainer.java @@ -0,0 +1,58 @@ +package org.testcontainers.typesense; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for Typesense. + *

+ * Supported image: {@code typesense/typesense} + *

+ * Exposed ports: 8108 + */ +public class TypesenseContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("typesense/typesense"); + + private static final int PORT = 8108; + + private static final String DEFAULT_API_KEY = "testcontainers"; + + private String apiKey = DEFAULT_API_KEY; + + public TypesenseContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public TypesenseContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withExposedPorts(PORT); + withEnv("TYPESENSE_DATA_DIR", "/tmp"); + waitingFor( + Wait + .forHttp("/health") + .forStatusCode(200) + .forResponsePredicate(response -> response.contains("\"ok\":true")) + ); + } + + @Override + protected void configure() { + withEnv("TYPESENSE_API_KEY", this.apiKey); + } + + public TypesenseContainer withApiKey(String apiKey) { + this.apiKey = apiKey; + return this; + } + + public String getHttpPort() { + return String.valueOf(getMappedPort(PORT)); + } + + public String getApiKey() { + return this.apiKey; + } +} diff --git a/modules/typesense/src/test/java/org/testcontainers/typesense/TypesenseContainerTest.java b/modules/typesense/src/test/java/org/testcontainers/typesense/TypesenseContainerTest.java new file mode 100644 index 00000000000..f833814a077 --- /dev/null +++ b/modules/typesense/src/test/java/org/testcontainers/typesense/TypesenseContainerTest.java @@ -0,0 +1,49 @@ +package org.testcontainers.typesense; + +import org.junit.jupiter.api.Test; +import org.typesense.api.Client; +import org.typesense.api.Configuration; +import org.typesense.resources.Node; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TypesenseContainerTest { + + @Test + void query() throws Exception { + try ( // container { + TypesenseContainer typesense = new TypesenseContainer("typesense/typesense:27.1") + // } + ) { + typesense.start(); + List nodes = Collections.singletonList( + new Node("http", typesense.getHost(), typesense.getHttpPort()) + ); + + assertThat(typesense.getApiKey()).isEqualTo("testcontainers"); + Configuration configuration = new Configuration(nodes, Duration.ofSeconds(5), typesense.getApiKey()); + Client client = new Client(configuration); + System.out.println(client.health.retrieve()); + assertThat(client.health.retrieve()).containsEntry("ok", true); + } + } + + @Test + void withCustomApiKey() throws Exception { + try (TypesenseContainer typesense = new TypesenseContainer("typesense/typesense:27.1").withApiKey("s3cr3t")) { + typesense.start(); + List nodes = Collections.singletonList( + new Node("http", typesense.getHost(), typesense.getHttpPort()) + ); + + assertThat(typesense.getApiKey()).isEqualTo("s3cr3t"); + Configuration configuration = new Configuration(nodes, Duration.ofSeconds(5), typesense.getApiKey()); + Client client = new Client(configuration); + assertThat(client.health.retrieve()).containsEntry("ok", true); + } + } +} diff --git a/modules/typesense/src/test/resources/logback-test.xml b/modules/typesense/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/typesense/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/vault/AUTHORS b/modules/vault/AUTHORS new file mode 100644 index 00000000000..528a7856aca --- /dev/null +++ b/modules/vault/AUTHORS @@ -0,0 +1 @@ +Michael Oswald \ No newline at end of file diff --git a/modules/vault/LICENSE b/modules/vault/LICENSE new file mode 100644 index 00000000000..957404f59b9 --- /dev/null +++ b/modules/vault/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Capital One Services, LLC and other authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/modules/vault/build.gradle b/modules/vault/build.gradle new file mode 100644 index 00000000000..bbf7929c4e7 --- /dev/null +++ b/modules/vault/build.gradle @@ -0,0 +1,8 @@ +description = "Testcontainers :: Vault" + +dependencies { + api project(':testcontainers') + + testImplementation 'com.bettercloud:vault-java-driver:5.1.0' + testImplementation 'io.rest-assured:rest-assured:5.5.7' +} diff --git a/modules/vault/src/main/java/org/testcontainers/vault/VaultContainer.java b/modules/vault/src/main/java/org/testcontainers/vault/VaultContainer.java new file mode 100644 index 00000000000..f29595b7ebb --- /dev/null +++ b/modules/vault/src/main/java/org/testcontainers/vault/VaultContainer.java @@ -0,0 +1,208 @@ +package org.testcontainers.vault; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.model.Capability; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * Testcontainers implementation for Vault. + *

+ * Supported image: {@code hashicorp/vault}, {@code vault} + *

+ * Exposure ports: 8200 + */ +public class VaultContainer> extends GenericContainer { + + private static final DockerImageName DEFAULT_OLD_IMAGE_NAME = DockerImageName.parse("vault"); + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("hashicorp/vault"); + + private static final String DEFAULT_TAG = "1.1.3"; + + private static final int VAULT_PORT = 8200; + + private Map> secretsMap = new HashMap<>(); + + private List initCommands = new ArrayList<>(); + + private int port = VAULT_PORT; + + /** + * @deprecated use {@link #VaultContainer(DockerImageName)} instead + */ + @Deprecated + public VaultContainer() { + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); + } + + public VaultContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public VaultContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_OLD_IMAGE_NAME, DEFAULT_IMAGE_NAME); + + // Use the vault healthcheck endpoint to check for readiness, per https://www.vaultproject.io/api/system/health.html + setWaitStrategy(Wait.forHttp("/v1/sys/health").forStatusCode(200)); + + withCreateContainerCmdModifier(cmd -> cmd.withCapAdd(Capability.IPC_LOCK)); + withEnv("VAULT_ADDR", "http://0.0.0.0:" + port); + withExposedPorts(port); + } + + public String getHttpHostAddress() { + return String.format("http://%s:%s", getHost(), getMappedPort(port)); + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + addSecrets(); + runInitCommands(); + } + + private void addSecrets() { + if (!secretsMap.isEmpty()) { + try { + this.execInContainer(buildExecCommand(secretsMap)).getStdout().contains("Success"); + } catch (IOException | InterruptedException e) { + logger() + .error( + "Failed to add these secrets {} into Vault via exec command. Exception message: {}", + secretsMap, + e.getMessage() + ); + } + } + } + + private String[] buildExecCommand(Map> map) { + StringBuilder stringBuilder = new StringBuilder(); + map.forEach((path, secrets) -> { + stringBuilder.append(" && vault kv put " + path); + secrets.forEach(item -> stringBuilder.append(" " + item)); + }); + return new String[] { "/bin/sh", "-c", stringBuilder.toString().substring(4) }; + } + + private void runInitCommands() { + if (!initCommands.isEmpty()) { + String commands = initCommands + .stream() + .map(command -> "vault " + command) + .collect(Collectors.joining(" && ")); + try { + ExecResult execResult = this.execInContainer(new String[] { "/bin/sh", "-c", commands }); + if (execResult.getExitCode() != 0) { + logger() + .error( + "Failed to execute these init commands {}. Exit code {}. Stdout {}. Stderr {}", + initCommands, + execResult.getExitCode(), + execResult.getStdout(), + execResult.getStderr() + ); + } + } catch (IOException | InterruptedException e) { + logger() + .error( + "Failed to execute these init commands {}. Exception message: {}", + initCommands, + e.getMessage() + ); + } + } + } + + /** + * Sets the Vault root token for the container so application tests can source secrets using the token + * + * @param token the root token value to set for Vault. + * @return this + */ + public SELF withVaultToken(String token) { + withEnv("VAULT_DEV_ROOT_TOKEN_ID", token); + withEnv("VAULT_TOKEN", token); + return self(); + } + + /** + * Sets the Vault port in the container as well as the port bindings for the host to reach the container over HTTP. + * + * @param port the port number you want to have the Vault container listen on for tests. + * @return this + * @deprecated the exposed port will be randomized automatically. As calling this method provides no additional value, you are recommended to remove the call. getFirstMappedPort() may be used to obtain the listening vault port. + */ + @Deprecated + public SELF withVaultPort(int port) { + this.port = port; + return self(); + } + + /** + * Sets the logging level for the Vault server in the container. + * Logs can be consumed through {@link #withLogConsumer(Consumer)}. + * + * @param level the logging level to set for Vault. + * @return this + * @deprecated use {@link #withEnv(String, String)} instead + */ + @Deprecated + public SELF withLogLevel(VaultLogLevel level) { + return withEnv("VAULT_LOG_LEVEL", level.config); + } + + /** + * Pre-loads secrets into Vault container. User may specify one or more secrets and all will be added to each path + * that is specified. Thus this can be called more than once for multiple paths to be added to Vault. + *

+ * The secrets are added to vault directly after the container is up via the + * {@link #addSecrets() addSecrets}, called from {@link #containerIsStarted(InspectContainerResponse) containerIsStarted} + * + * @param path specific Vault path to store specified secrets + * @param firstSecret first secret to add to specified path + * @param remainingSecrets var args list of secrets to add to specified path + * @return this + * @deprecated use {@link #withInitCommand(String...)} instead + */ + @Deprecated + public SELF withSecretInVault(String path, String firstSecret, String... remainingSecrets) { + List list = new ArrayList<>(); + list.add(firstSecret); + for (String secret : remainingSecrets) { + list.add(secret); + } + if (secretsMap.containsKey(path)) { + list.addAll(list); + } + secretsMap.putIfAbsent(path, list); + return self(); + } + + /** + * Run initialization commands using the vault cli. + *

+ * Useful for enabling more secret engines like: + *

+     *     .withInitCommand("secrets enable pki")
+     *     .withInitCommand("secrets enable transit")
+     * 
+ * @param commands The commands to send to the vault cli + * @return this + */ + public SELF withInitCommand(String... commands) { + initCommands.addAll(Arrays.asList(commands)); + return self(); + } +} diff --git a/modules/vault/src/main/java/org/testcontainers/vault/VaultLogLevel.java b/modules/vault/src/main/java/org/testcontainers/vault/VaultLogLevel.java new file mode 100644 index 00000000000..e5f0db784a8 --- /dev/null +++ b/modules/vault/src/main/java/org/testcontainers/vault/VaultLogLevel.java @@ -0,0 +1,18 @@ +package org.testcontainers.vault; + +/** + * Vault preset of logging levels. + */ +public enum VaultLogLevel { + Trace("trace"), + Debug("debug"), + Info("info"), + Warn("warn"), + Error("err"); + + public final String config; + + VaultLogLevel(String config) { + this.config = config; + } +} diff --git a/modules/vault/src/test/java/org/testcontainers/vault/VaultClientTest.java b/modules/vault/src/test/java/org/testcontainers/vault/VaultClientTest.java new file mode 100644 index 00000000000..92f4a6632b9 --- /dev/null +++ b/modules/vault/src/test/java/org/testcontainers/vault/VaultClientTest.java @@ -0,0 +1,45 @@ +package org.testcontainers.vault; + +import com.bettercloud.vault.Vault; +import com.bettercloud.vault.VaultConfig; +import com.bettercloud.vault.VaultException; +import com.bettercloud.vault.response.LogicalResponse; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class VaultClientTest { + + private static final String VAULT_TOKEN = "my-root-token"; + + @Test + void writeAndReadMultipleValues() throws VaultException { + try (VaultContainer vaultContainer = new VaultContainer<>("vault:1.1.3").withVaultToken(VAULT_TOKEN)) { + vaultContainer.start(); + + final VaultConfig config = new VaultConfig() + .address("http://" + vaultContainer.getHost() + ":" + vaultContainer.getFirstMappedPort()) + .token(VAULT_TOKEN) + .build(); + + final Vault vault = new Vault(config); + + final Map secrets = new HashMap<>(); + secrets.put("value", "world"); + secrets.put("other_value", "another world"); + + // Write operation + final LogicalResponse writeResponse = vault.logical().write("secret/hello", secrets); + + assertThat(writeResponse.getRestResponse().getStatus()).isEqualTo(200); + + // Read operation + final Map value = vault.logical().read("secret/hello").getData(); + + assertThat(value).containsEntry("value", "world").containsEntry("other_value", "another world"); + } + } +} diff --git a/modules/vault/src/test/java/org/testcontainers/vault/VaultContainerTest.java b/modules/vault/src/test/java/org/testcontainers/vault/VaultContainerTest.java new file mode 100644 index 00000000000..cc2d8b37d73 --- /dev/null +++ b/modules/vault/src/test/java/org/testcontainers/vault/VaultContainerTest.java @@ -0,0 +1,170 @@ +package org.testcontainers.vault; + +import com.bettercloud.vault.Vault; +import com.bettercloud.vault.VaultConfig; +import com.bettercloud.vault.response.LogicalResponse; +import io.restassured.response.Response; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; + +import java.util.HashMap; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * This test shows the pattern to use the VaultContainer @ClassRule for a junit test. It also has tests that ensure + * the secrets were added correctly by reading from Vault with the CLI, over HTTP and over Client Library. + */ +class VaultContainerTest { + + private static final String VAULT_TOKEN = "my-root-token"; + + // vaultContainer { + public static VaultContainer vaultContainer = new VaultContainer<>("hashicorp/vault:1.13") + .withVaultToken(VAULT_TOKEN) + .withInitCommand( + "secrets enable transit", + "write -f transit/keys/my-key", + "kv put secret/testing1 top_secret=password123", + "kv put secret/testing2 secret_one=password1 secret_two=password2 secret_three=password3 secret_three=password3 secret_four=password4" + ); + + // } + + @BeforeAll + static void setUp() { + vaultContainer.start(); + } + + @AfterAll + static void tearDown() { + vaultContainer.stop(); + } + + @Test + void readFirstSecretPathWithCli() throws Exception { + GenericContainer.ExecResult result = vaultContainer.execInContainer( + "vault", + "kv", + "get", + "-format=json", + "secret/testing1" + ); + assertThat(result.getStdout()).contains("password123"); + } + + @Test + void readSecondSecretPathWithCli() throws Exception { + GenericContainer.ExecResult result = vaultContainer.execInContainer( + "vault", + "kv", + "get", + "-format=json", + "secret/testing2" + ); + + final String output = result.getStdout().replaceAll("\\r?\\n", ""); + System.out.println("output = " + output); + assertThat(output).contains("password1"); + assertThat(output).contains("password2"); + assertThat(output).contains("password3"); + assertThat(output).contains("password4"); + } + + @Test + void readFirstSecretPathOverHttpApi() { + Response response = given() + .header("X-Vault-Token", VAULT_TOKEN) + .when() + .get(vaultContainer.getHttpHostAddress() + "/v1/secret/data/testing1") + .thenReturn(); + assertThat(response.body().jsonPath().getString("data.data.top_secret")).isEqualTo("password123"); + } + + @Test + void readSecondSecretPathOverHttpApi() throws InterruptedException { + Response response = given() + .header("X-Vault-Token", VAULT_TOKEN) + .when() + .get(vaultContainer.getHttpHostAddress() + "/v1/secret/data/testing2") + .andReturn(); + + assertThat(response.body().jsonPath().getString("data.data.secret_one")).contains("password1"); + assertThat(response.body().jsonPath().getString("data.data.secret_two")).contains("password2"); + assertThat(response.body().jsonPath().getList("data.data.secret_three")).contains("password3"); + assertThat(response.body().jsonPath().getString("data.data.secret_four")).contains("password4"); + } + + @Test + void readTransitKeyOverHttpApi() throws InterruptedException { + Response response = given() + .header("X-Vault-Token", VAULT_TOKEN) + .when() + .get(vaultContainer.getHttpHostAddress() + "/v1/transit/keys/my-key") + .thenReturn(); + + assertThat(response.body().jsonPath().getString("data.name")).isEqualTo("my-key"); + } + + @Test + // readWithLibrary { + void readFirstSecretPathOverClientLibrary() throws Exception { + final VaultConfig config = new VaultConfig() + .address(vaultContainer.getHttpHostAddress()) + .token(VAULT_TOKEN) + .build(); + + final Vault vault = new Vault(config); + + final Map value = vault.logical().read("secret/testing1").getData(); + + assertThat(value).containsEntry("top_secret", "password123"); + } + + // } + + @Test + void readSecondSecretPathOverClientLibrary() throws Exception { + final VaultConfig config = new VaultConfig() + .address(vaultContainer.getHttpHostAddress()) + .token(VAULT_TOKEN) + .build(); + + final Vault vault = new Vault(config); + final Map value = vault.logical().read("secret/testing2").getData(); + + assertThat(value) + .containsEntry("secret_one", "password1") + .containsEntry("secret_two", "password2") + .containsEntry("secret_three", "[\"password3\",\"password3\"]") + .containsEntry("secret_four", "password4"); + } + + @Test + void writeSecretOverClientLibrary() throws Exception { + final VaultConfig config = new VaultConfig() + .address(vaultContainer.getHttpHostAddress()) + .token(VAULT_TOKEN) + .build(); + + final Vault vault = new Vault(config); + + final Map secrets = new HashMap<>(); + secrets.put("value", "world"); + secrets.put("other_value", "another world"); + + // Write operation + final LogicalResponse writeResponse = vault.logical().write("secret/hello", secrets); + + assertThat(writeResponse.getRestResponse().getStatus()).isEqualTo(200); + + // Read operation + final Map value = vault.logical().read("secret/hello").getData(); + + assertThat(value).containsEntry("value", "world").containsEntry("other_value", "another world"); + } +} diff --git a/modules/vault/src/test/resources/logback-test.xml b/modules/vault/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/vault/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/virtuoso/pom.xml b/modules/virtuoso/pom.xml deleted file mode 100644 index 0db625f83ef..00000000000 --- a/modules/virtuoso/pom.xml +++ /dev/null @@ -1,56 +0,0 @@ - - 4.0.0 - - - org.testcontainers - testcontainers-parent - 1.2.1-SNAPSHOT - ../../pom.xml - - - virtuoso - TestContainers :: JDBC :: Virtuoso - - - - ldstack - http://mvn.linkeddata.org/content/groups/linkeddata.org/ - - - - - - ${project.groupId} - testcontainers - ${project.version} - - - ${project.groupId} - jdbc - ${project.version} - - - - - virtuoso - jdbc-driver - 4.0 - test - - - - - com.zaxxer - HikariCP-java6 - 2.3.8 - test - - - commons-dbutils - commons-dbutils - 1.6 - test - - - - \ No newline at end of file diff --git a/modules/virtuoso/src/main/java/org/testcontainers/containers/VirtuosoContainer.java b/modules/virtuoso/src/main/java/org/testcontainers/containers/VirtuosoContainer.java deleted file mode 100644 index 3f601b22812..00000000000 --- a/modules/virtuoso/src/main/java/org/testcontainers/containers/VirtuosoContainer.java +++ /dev/null @@ -1,106 +0,0 @@ -package org.testcontainers.containers; - -import org.rnorth.ducttape.inconsistents.Inconsistents; -import org.rnorth.ducttape.ratelimits.RateLimiter; -import org.rnorth.ducttape.ratelimits.RateLimiterBuilder; - -import java.sql.Connection; -import java.sql.SQLException; -import java.util.concurrent.TimeUnit; - -public class VirtuosoContainer> extends JdbcDatabaseContainer { - - public static final String NAME = "virtuoso"; - public static final String IMAGE = "tenforce/virtuoso"; - public static final Integer JDBC_PORT = 1111; - public static final Integer SPARQL_SERVICE_PORT = 8890; - private static final RateLimiter LIVENESS_RATE_LIMITER = RateLimiterBuilder.newBuilder() - .withConstantThroughput() - .withRate(1, TimeUnit.SECONDS) - .build(); - - public VirtuosoContainer() { - super(IMAGE + ":1.0.0-virtuoso7.2.2"); - } - - public VirtuosoContainer(String dockerImageName) { - super(dockerImageName); - } - - @Override - protected void configure() { - addExposedPort(JDBC_PORT); - addExposedPort(SPARQL_SERVICE_PORT); - addEnv("DBA_PASSWORD", getPassword()); - addEnv("SPARQL_UPDATE", "true"); - addEnv("DEFAULT_GRAPH", "http://localhost:8890/DAV"); - addExposedPorts(JDBC_PORT, SPARQL_SERVICE_PORT); - } - - @Override - protected String getDriverClassName() { - return "virtuoso.jdbc4.Driver"; - } - - @Override - public String getJdbcUrl() { - return "jdbc:virtuoso://" + getContainerIpAddress() + ":" + getMappedPort(JDBC_PORT); - } - - public String getSparqlUrl() { - return "http://" + getContainerIpAddress() + ":" + getMappedPort(SPARQL_SERVICE_PORT) + "/sparql"; - } - - @Override - public String getUsername() { - return "dba"; - } - - @Override - public String getPassword() { - return "myDbaPassword"; - } - - @Override - protected String getTestQueryString() { - return "SELECT 1"; - } - - @Override - protected Integer getLivenessCheckPort() { - return getMappedPort(JDBC_PORT); - } - - @Override - protected void waitUntilContainerStarted() { - // Repeatedly try and open a connection to the DB and execute a test query - - logger().info("Waiting for database connection to become available at {} using query '{}'", getJdbcUrl(), getTestQueryString()); - - // Wait for consecutive JDBC connection successes over a period of time. The Virtuoso container seems - // to initially return a connection that fails on subsequent attempts, so wait for a consistently stable connection - Inconsistents.retryUntilConsistent(5, 120, TimeUnit.SECONDS, () -> { - //noinspection CodeBlock2Expr - return LIVENESS_RATE_LIMITER.getWhenReady(() -> { - if (!isRunning()) { - throw new ContainerLaunchException("Container failed to start"); - } - - try { - Connection connection = createConnection(""); - - boolean success = connection.createStatement().execute(this.getTestQueryString()); - - if (success) { - logger().info("Obtained a connection to container ({})", this.getJdbcUrl()); - return true; - } else { - throw new SQLException("Failed to execute test query"); - } - } catch (SQLException e) { - throw new ContainerLaunchException(e.getMessage()); - } - }); - }); - } -} diff --git a/modules/virtuoso/src/main/java/org/testcontainers/containers/VirtuosoContainerProvider.java b/modules/virtuoso/src/main/java/org/testcontainers/containers/VirtuosoContainerProvider.java deleted file mode 100644 index b98f809078f..00000000000 --- a/modules/virtuoso/src/main/java/org/testcontainers/containers/VirtuosoContainerProvider.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.testcontainers.containers; - -import org.testcontainers.containers.JdbcDatabaseContainerProvider; - -public class VirtuosoContainerProvider extends JdbcDatabaseContainerProvider { - - @Override - public boolean supports(String databaseType) { - return databaseType.equals(VirtuosoContainer.NAME); - } - - @Override - public VirtuosoContainer newInstance(String tag) { - return new VirtuosoContainer(VirtuosoContainer.IMAGE + ":" + tag); - } - -} diff --git a/modules/virtuoso/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/virtuoso/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider deleted file mode 100644 index c17bf3f8196..00000000000 --- a/modules/virtuoso/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider +++ /dev/null @@ -1 +0,0 @@ -org.testcontainers.containers.VirtuosoContainerProvider \ No newline at end of file diff --git a/modules/virtuoso/src/test/java/org/testcontainers/junit/SimpleVirtuosoTest.java b/modules/virtuoso/src/test/java/org/testcontainers/junit/SimpleVirtuosoTest.java deleted file mode 100644 index c6d6cd1b17f..00000000000 --- a/modules/virtuoso/src/test/java/org/testcontainers/junit/SimpleVirtuosoTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.testcontainers.junit; - -import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; -import org.junit.Rule; -import org.junit.Test; -import org.testcontainers.containers.VirtuosoContainer; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; - -public class SimpleVirtuosoTest { - - @Rule - public VirtuosoContainer virtuoso = new VirtuosoContainer(); - - @Test - public void testSimple() throws SQLException { - HikariConfig hikariConfig = new HikariConfig(); - hikariConfig.setJdbcUrl(virtuoso.getJdbcUrl()); - hikariConfig.setUsername(virtuoso.getUsername()); - hikariConfig.setPassword(virtuoso.getPassword()); - hikariConfig.setConnectionTestQuery("SELECT 1"); - - HikariDataSource ds = new HikariDataSource(hikariConfig); - Statement statement = ds.getConnection().createStatement(); - statement.execute("SELECT 1"); - ResultSet resultSet = statement.getResultSet(); - - resultSet.next(); - int resultSetInt = resultSet.getInt(1); - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } -} diff --git a/modules/virtuoso/src/test/resources/logback-test.xml b/modules/virtuoso/src/test/resources/logback-test.xml deleted file mode 100644 index 3160c151056..00000000000 --- a/modules/virtuoso/src/test/resources/logback-test.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - %d{HH:mm:ss.SSS} %-5level %logger - %msg%n - - - - - - - - - - - - - - - - - - - - PROFILER - DENY - - \ No newline at end of file diff --git a/modules/weaviate/build.gradle b/modules/weaviate/build.gradle new file mode 100644 index 00000000000..404329b161f --- /dev/null +++ b/modules/weaviate/build.gradle @@ -0,0 +1,20 @@ +description = "Testcontainers :: Weaviate" + +dependencies { + api project(':testcontainers') + + testImplementation 'io.weaviate:client6:6.3.0' +} + +test { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(17) + } +} + +compileTestJava { + javaCompiler = javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(17) + } + options.release.set(17) +} diff --git a/modules/weaviate/src/main/java/org/testcontainers/weaviate/WeaviateContainer.java b/modules/weaviate/src/main/java/org/testcontainers/weaviate/WeaviateContainer.java new file mode 100644 index 00000000000..dbc18a49a22 --- /dev/null +++ b/modules/weaviate/src/main/java/org/testcontainers/weaviate/WeaviateContainer.java @@ -0,0 +1,58 @@ +package org.testcontainers.weaviate; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation of Weaviate. + *

+ * Supported images: {@code cr.weaviate.io/semitechnologies/weaviate}, {@code semitechnologies/weaviate} + *

+ * Exposed ports: + *

    + *
  • HTTP: 8080
  • + *
  • gRPC: 50051
  • + *
+ */ +public class WeaviateContainer extends GenericContainer { + + private static final int HTTP_PORT = 8080; + + private static final int GRPC_PORT = 50051; + + private static final DockerImageName DEFAULT_WEAVIATE_IMAGE = DockerImageName.parse( + "cr.weaviate.io/semitechnologies/weaviate" + ); + + private static final DockerImageName DOCKER_HUB_WEAVIATE_IMAGE = DockerImageName.parse("semitechnologies/weaviate"); + + public WeaviateContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public WeaviateContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_WEAVIATE_IMAGE, DOCKER_HUB_WEAVIATE_IMAGE); + withExposedPorts(HTTP_PORT, GRPC_PORT); + withEnv("AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED", "true"); + withEnv("PERSISTENCE_DATA_PATH", "/var/lib/weaviate"); + waitingFor(Wait.forHttp("/v1/.well-known/ready").forPort(HTTP_PORT).forStatusCode(200)); + } + + public String getHttpHostAddress() { + return getHost() + ":" + getHttpPort(); + } + + public Integer getHttpPort() { + return getMappedPort(HTTP_PORT); + } + + public String getGrpcHostAddress() { + return getHost() + ":" + getGrpcPort(); + } + + public Integer getGrpcPort() { + return getMappedPort(GRPC_PORT); + } +} diff --git a/modules/weaviate/src/test/java/org/testcontainers/weaviate/WeaviateContainerTest.java b/modules/weaviate/src/test/java/org/testcontainers/weaviate/WeaviateContainerTest.java new file mode 100644 index 00000000000..098a1561913 --- /dev/null +++ b/modules/weaviate/src/test/java/org/testcontainers/weaviate/WeaviateContainerTest.java @@ -0,0 +1,78 @@ +package org.testcontainers.weaviate; + +import io.weaviate.client6.v1.api.InstanceMetadata; +import io.weaviate.client6.v1.api.WeaviateClient; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class WeaviateContainerTest { + + @Test + void testWeaviate() throws Exception { + try ( // container { + WeaviateContainer weaviate = new WeaviateContainer("cr.weaviate.io/semitechnologies/weaviate:1.32.0") + // } + ) { + weaviate.start(); + try ( + WeaviateClient client = WeaviateClient.connectToCustom(conn -> { + return conn + .scheme("http") + .httpHost(weaviate.getHost()) + .httpPort(weaviate.getHttpPort()) + .grpcHost(weaviate.getHost()) + .grpcPort(weaviate.getGrpcPort()); + }) + ) { + InstanceMetadata meta = client.meta(); + assertThat(meta.version()).isEqualTo("1.32.0"); + } + } + } + + @Test + void testWeaviateWithModules() throws Exception { + List enableModules = Arrays.asList( + "backup-filesystem", + "text2vec-openai", + "text2vec-cohere", + "text2vec-huggingface", + "generative-openai" + ); + Map env = new HashMap<>(); + env.put("ENABLE_MODULES", String.join(",", enableModules)); + env.put("BACKUP_FILESYSTEM_PATH", "/tmp/backups"); + try (WeaviateContainer weaviate = new WeaviateContainer("semitechnologies/weaviate:1.32.0").withEnv(env)) { + weaviate.start(); + try ( + WeaviateClient client = WeaviateClient.connectToCustom(conn -> { + return conn + .scheme("http") + .httpHost(weaviate.getHost()) + .httpPort(weaviate.getHttpPort()) + .grpcHost(weaviate.getHost()) + .grpcPort(weaviate.getGrpcPort()); + }) + ) { + InstanceMetadata meta = client.meta(); + assertThat(meta.version()).isEqualTo("1.32.0"); + Map modules = meta.modules(); + assertThat(modules) + .isNotNull() + .asInstanceOf(InstanceOfAssertFactories.map(String.class, Object.class)) + .extracting(Map::keySet) + .satisfies(keys -> { + assertThat(keys.size()).isEqualTo(enableModules.size()); + keys.forEach(key -> assertThat(enableModules.contains(key)).isTrue()); + }); + } + } + } +} diff --git a/modules/weaviate/src/test/resources/logback-test.xml b/modules/weaviate/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/weaviate/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/yugabytedb/build.gradle b/modules/yugabytedb/build.gradle new file mode 100644 index 00000000000..4351653b4f3 --- /dev/null +++ b/modules/yugabytedb/build.gradle @@ -0,0 +1,11 @@ +description = "Testcontainers :: JDBC :: YugabyteDB" + +dependencies { + api project(':testcontainers-jdbc') + + testImplementation project(':testcontainers-jdbc-test') + // YCQL driver + testImplementation 'com.yugabyte:java-driver-core:4.19.0-yb-1' + // YSQL driver + testRuntimeOnly 'com.yugabyte:jdbc-yugabytedb:42.7.3-yb-4' +} diff --git a/modules/yugabytedb/src/main/java/org/testcontainers/containers/YugabyteDBYCQLContainer.java b/modules/yugabytedb/src/main/java/org/testcontainers/containers/YugabyteDBYCQLContainer.java new file mode 100644 index 00000000000..d79812f247f --- /dev/null +++ b/modules/yugabytedb/src/main/java/org/testcontainers/containers/YugabyteDBYCQLContainer.java @@ -0,0 +1,175 @@ +package org.testcontainers.containers; + +import com.github.dockerjava.api.command.InspectContainerResponse; +import org.testcontainers.containers.delegate.YugabyteDBYCQLDelegate; +import org.testcontainers.containers.strategy.YugabyteDBYCQLWaitStrategy; +import org.testcontainers.ext.ScriptUtils; +import org.testcontainers.utility.DockerImageName; + +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.Collections; +import java.util.Set; + +/** + * Testcontainers implementation for YugabyteDB YCQL API. + *

+ * Supported image: {@code yugabytedb/yugabyte} + *

+ * Exposed ports: + *

    + *
  • YCQL: 5433
  • + *
  • Master dashboard: 7000
  • + *
  • Tserver dashboard: 9000
  • + *
+ * + * @see YCQL API + */ +public class YugabyteDBYCQLContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("yugabytedb/yugabyte"); + + private static final Integer YCQL_PORT = 9042; + + private static final Integer MASTER_DASHBOARD_PORT = 7000; + + private static final Integer TSERVER_DASHBOARD_PORT = 9000; + + private static final String ENTRYPOINT = "bin/yugabyted start --background=false"; + + private static final String LOCAL_DC = "datacenter1"; + + private String keyspace; + + private String username; + + private String password; + + private String initScript; + + /** + * @param imageName image name + */ + public YugabyteDBYCQLContainer(final String imageName) { + this(DockerImageName.parse(imageName)); + } + + /** + * @param imageName image name + */ + public YugabyteDBYCQLContainer(final DockerImageName imageName) { + super(imageName); + imageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withExposedPorts(YCQL_PORT, MASTER_DASHBOARD_PORT, TSERVER_DASHBOARD_PORT); + waitingFor(new YugabyteDBYCQLWaitStrategy(this).withStartupTimeout(Duration.ofSeconds(60))); + withCommand(ENTRYPOINT); + } + + @Override + public Set getLivenessCheckPortNumbers() { + return Collections.singleton(getMappedPort(YCQL_PORT)); + } + + /** + * Configures the environment variables. Setting up these variables would create the + * custom objects. Setting {@link #withKeyspaceName(String)}, + * {@link #withUsername(String)}, {@link #withPassword(String)} these parameters will + * initialize the database with those custom values + */ + @Override + protected void configure() { + addEnv("YCQL_KEYSPACE", keyspace); + addEnv("YCQL_USER", username); + addEnv("YCQL_PASSWORD", password); + } + + /** + * @param initScript path of the initialization script file + * @return {@link YugabyteDBYCQLContainer} instance + */ + public YugabyteDBYCQLContainer withInitScript(String initScript) { + this.initScript = initScript; + return this; + } + + /** + * Setting this would create the keyspace + * @param keyspace keyspace + * @return {@link YugabyteDBYCQLContainer} instance + */ + public YugabyteDBYCQLContainer withKeyspaceName(final String keyspace) { + this.keyspace = keyspace; + return this; + } + + /** + * Setting this would create the custom user role + * @param username user name + * @return {@link YugabyteDBYCQLContainer} instance + */ + public YugabyteDBYCQLContainer withUsername(final String username) { + this.username = username; + return this; + } + + /** + * Setting this along with {@link #withUsername(String)} would enable authentication + * @param password password + * @return {@link YugabyteDBYCQLContainer} instance + */ + public YugabyteDBYCQLContainer withPassword(final String password) { + this.password = password; + return this; + } + + /** + * Executes the initialization script + * @param containerInfo containerInfo + */ + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo) { + if (this.initScript != null) { + ScriptUtils.runInitScript(new YugabyteDBYCQLDelegate(this), initScript); + } + } + + /** + * Returns a {@link InetSocketAddress} representation of YCQL's contact point info + * @return contactpoint + */ + public InetSocketAddress getContactPoint() { + return new InetSocketAddress(getHost(), getMappedPort(YCQL_PORT)); + } + + /** + * Returns the local datacenter name + * @return localdc name + */ + public String getLocalDc() { + return LOCAL_DC; + } + + /** + * Username getter method + * @return username + */ + public String getUsername() { + return this.username; + } + + /** + * Password getter method + * @return password + */ + public String getPassword() { + return this.password; + } + + /** + * Keyspace getter method + * @return keyspace + */ + public String getKeyspace() { + return this.keyspace; + } +} diff --git a/modules/yugabytedb/src/main/java/org/testcontainers/containers/YugabyteDBYSQLContainer.java b/modules/yugabytedb/src/main/java/org/testcontainers/containers/YugabyteDBYSQLContainer.java new file mode 100644 index 00000000000..aae3f67b313 --- /dev/null +++ b/modules/yugabytedb/src/main/java/org/testcontainers/containers/YugabyteDBYSQLContainer.java @@ -0,0 +1,162 @@ +package org.testcontainers.containers; + +import org.testcontainers.containers.strategy.YugabyteDBYSQLWaitStrategy; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.Collections; +import java.util.Set; + +/** + * Testcontainers implementation for YugabyteDB YSQL API. + *

+ * Supported image: {@code yugabytedb/yugabyte} + *

+ * Exposed ports: + *

    + *
  • YSQL: 5433
  • + *
  • Master dashboard: 7000
  • + *
  • Tserver dashboard: 9000
  • + *
+ * + * @see YSQL API + */ +public class YugabyteDBYSQLContainer extends JdbcDatabaseContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("yugabytedb/yugabyte"); + + private static final Integer YSQL_PORT = 5433; + + private static final Integer MASTER_DASHBOARD_PORT = 7000; + + private static final Integer TSERVER_DASHBOARD_PORT = 9000; + + private static final String JDBC_DRIVER_CLASS = "com.yugabyte.Driver"; + + private static final String JDBC_CONNECT_PREFIX = "jdbc:yugabytedb"; + + private static final String ENTRYPOINT = "bin/yugabyted start --background=false"; + + private String database = "yugabyte"; + + private String username = "yugabyte"; + + private String password = "yugabyte"; + + /** + * @param imageName image name + */ + public YugabyteDBYSQLContainer(final String imageName) { + this(DockerImageName.parse(imageName)); + } + + /** + * @param imageName image name + */ + public YugabyteDBYSQLContainer(final DockerImageName imageName) { + super(imageName); + imageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + withExposedPorts(YSQL_PORT, MASTER_DASHBOARD_PORT, TSERVER_DASHBOARD_PORT); + waitingFor(new YugabyteDBYSQLWaitStrategy(this).withStartupTimeout(Duration.ofSeconds(60))); + withCommand(ENTRYPOINT); + } + + @Override + public Set getLivenessCheckPortNumbers() { + return Collections.singleton(getMappedPort(YSQL_PORT)); + } + + /** + * Configures the environment variables. Setting up these variables would create the + * custom objects. Setting {@link #withDatabaseName(String)}, + * {@link #withUsername(String)}, {@link #withPassword(String)} these parameters will + * initialize the database with those custom values + */ + + @Override + protected void configure() { + addEnv("YSQL_DB", database); + addEnv("YSQL_USER", username); + addEnv("YSQL_PASSWORD", password); + } + + @Override + public String getDriverClassName() { + return JDBC_DRIVER_CLASS; + } + + @Override + public String getJdbcUrl() { + return ( + JDBC_CONNECT_PREFIX + + "://" + + getHost() + + ":" + + getMappedPort(YSQL_PORT) + + "/" + + database + + constructUrlParameters("?", "&") + ); + } + + @Override + public String getDatabaseName() { + return database; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getTestQueryString() { + return "SELECT 1"; + } + + /** + * Setting this would create the keyspace + * @param database database name + * @return {@link YugabyteDBYSQLContainer} instance + */ + + @Override + public YugabyteDBYSQLContainer withDatabaseName(final String database) { + this.database = database; + return this; + } + + /** + * Setting this would create the custom user role + * @param username user name + * @return {@link YugabyteDBYSQLContainer} instance + */ + + @Override + public YugabyteDBYSQLContainer withUsername(final String username) { + this.username = username; + return this; + } + + /** + * Setting this along with {@link #withUsername(String)} would enable authentication + * @param password password + * @return {@link YugabyteDBYSQLContainer} instance + */ + + @Override + public YugabyteDBYSQLContainer withPassword(final String password) { + this.password = password; + return this; + } + + @Override + protected void waitUntilContainerStarted() { + getWaitStrategy().waitUntilReady(this); + } +} diff --git a/modules/yugabytedb/src/main/java/org/testcontainers/containers/YugabyteDBYSQLContainerProvider.java b/modules/yugabytedb/src/main/java/org/testcontainers/containers/YugabyteDBYSQLContainerProvider.java new file mode 100644 index 00000000000..4f1d0419543 --- /dev/null +++ b/modules/yugabytedb/src/main/java/org/testcontainers/containers/YugabyteDBYSQLContainerProvider.java @@ -0,0 +1,40 @@ +package org.testcontainers.containers; + +import org.testcontainers.jdbc.ConnectionUrl; +import org.testcontainers.utility.DockerImageName; + +/** + * YugabyteDB YSQL (Structured Query Language) JDBC container provider + */ +public class YugabyteDBYSQLContainerProvider extends JdbcDatabaseContainerProvider { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("yugabytedb/yugabyte"); + + private static final String DEFAULT_TAG = "2.14.4.0-b26"; + + private static final String NAME = "yugabyte"; + + private static final String USER_PARAM = "user"; + + private static final String PASSWORD_PARAM = "password"; + + @Override + public boolean supports(String databaseType) { + return databaseType.equals(NAME); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(DEFAULT_TAG); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + return new YugabyteDBYSQLContainer(DEFAULT_IMAGE_NAME.withTag(tag)); + } + + @Override + public JdbcDatabaseContainer newInstance(ConnectionUrl connectionUrl) { + return newInstanceFromConnectionUrl(connectionUrl, USER_PARAM, PASSWORD_PARAM); + } +} diff --git a/modules/yugabytedb/src/main/java/org/testcontainers/containers/delegate/AbstractYCQLDelegate.java b/modules/yugabytedb/src/main/java/org/testcontainers/containers/delegate/AbstractYCQLDelegate.java new file mode 100644 index 00000000000..4f1f2b24d8f --- /dev/null +++ b/modules/yugabytedb/src/main/java/org/testcontainers/containers/delegate/AbstractYCQLDelegate.java @@ -0,0 +1,25 @@ +package org.testcontainers.containers.delegate; + +import org.testcontainers.delegate.DatabaseDelegate; + +/** + * An abstract delegate do-nothing class + */ +public abstract class AbstractYCQLDelegate implements DatabaseDelegate { + + @Override + public void execute( + String statement, + String scriptPath, + int lineNumber, + boolean continueOnError, + boolean ignoreFailedDrops + ) { + // do nothing + } + + @Override + public void close() { + // do nothing + } +} diff --git a/modules/yugabytedb/src/main/java/org/testcontainers/containers/delegate/YugabyteDBYCQLDelegate.java b/modules/yugabytedb/src/main/java/org/testcontainers/containers/delegate/YugabyteDBYCQLDelegate.java new file mode 100644 index 00000000000..68998538fd4 --- /dev/null +++ b/modules/yugabytedb/src/main/java/org/testcontainers/containers/delegate/YugabyteDBYCQLDelegate.java @@ -0,0 +1,67 @@ +package org.testcontainers.containers.delegate; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.testcontainers.containers.Container.ExecResult; +import org.testcontainers.containers.YugabyteDBYCQLContainer; +import org.testcontainers.ext.ScriptUtils.UncategorizedScriptException; + +import java.util.Collection; + +/** + * Query execution delegate class for YCQL API to delegate init-script statements. This + * invokes the in-built ycqlsh cli within the container to execute the + * statements at one go. It is recommended to use frameworks such as liquibase to manage + * this requirement. This functionality is kept to address the initialization requirements + * from standalone services that can't leverage liquibase or something similar. + * + * @see YugabyteDBYCQLContainer + */ +@RequiredArgsConstructor +@Slf4j +public final class YugabyteDBYCQLDelegate extends AbstractYCQLDelegate { + + private static final String BIN_PATH = "/home/yugabyte/tserver/bin/ycqlsh"; + + private final YugabyteDBYCQLContainer container; + + @Override + public void execute( + Collection statements, + String scriptPath, + boolean continueOnError, + boolean ignoreFailedDrops + ) { + final String containerInterfaceIP = container + .getContainerInfo() + .getNetworkSettings() + .getNetworks() + .entrySet() + .stream() + .findFirst() + .get() + .getValue() + .getIpAddress(); + try { + ExecResult result = container.execInContainer( + BIN_PATH, + containerInterfaceIP, + "-u", + container.getUsername(), + "-p", + container.getPassword(), + "-k", + container.getKeyspace(), + "-e", + StringUtils.join(statements, ";") + ); + if (result.getExitCode() != 0) { + throw new RuntimeException(result.getStderr()); + } + } catch (Exception e) { + log.debug(e.getMessage(), e); + throw new UncategorizedScriptException(e.getMessage(), e); + } + } +} diff --git a/modules/yugabytedb/src/main/java/org/testcontainers/containers/strategy/YugabyteDBYCQLWaitStrategy.java b/modules/yugabytedb/src/main/java/org/testcontainers/containers/strategy/YugabyteDBYCQLWaitStrategy.java new file mode 100644 index 00000000000..9b9d24a4c71 --- /dev/null +++ b/modules/yugabytedb/src/main/java/org/testcontainers/containers/strategy/YugabyteDBYCQLWaitStrategy.java @@ -0,0 +1,91 @@ +package org.testcontainers.containers.strategy; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.containers.Container.ExecResult; +import org.testcontainers.containers.YugabyteDBYCQLContainer; +import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess; + +/** + * Custom wait strategy for YCQL API. + * + *

+ * Though we can either use HTTP or PORT based wait strategy, when we create a custom + * keyspace/role, it gets executed asynchronously. As the wait on container.start() on a + * specific port wouldn't fully guarantee the custom object execution. It's better to + * check the DB status with this way with a smoke test query that uses the underlying + * custom objects and wait for the operation to complete. + *

+ */ +@RequiredArgsConstructor +@Slf4j +public final class YugabyteDBYCQLWaitStrategy extends AbstractWaitStrategy { + + private static final String YCQL_TEST_QUERY = "SELECT release_version FROM system.local"; + + private static final String BIN_PATH = "/home/yugabyte/tserver/bin/ycqlsh"; + + private final WaitStrategyTarget target; + + @Override + public void waitUntilReady(WaitStrategyTarget target) { + YugabyteDBYCQLContainer container = (YugabyteDBYCQLContainer) target; + AtomicBoolean status = new AtomicBoolean(true); + final String containerInterfaceIP = container + .getContainerInfo() + .getNetworkSettings() + .getNetworks() + .entrySet() + .stream() + .findFirst() + .get() + .getValue() + .getIpAddress(); + retryUntilSuccess( + (int) startupTimeout.getSeconds(), + TimeUnit.SECONDS, + () -> { + YugabyteDBYCQLWaitStrategy.this.getRateLimiter() + .doWhenReady(() -> { + try { + ExecResult result = container.execInContainer( + BIN_PATH, + containerInterfaceIP, + "-u", + container.getUsername(), + "-p", + container.getPassword(), + "-k", + container.getKeyspace(), + "-e", + YCQL_TEST_QUERY + ); + if (result.getExitCode() != 0) { + status.set(false); + log.debug(result.getStderr()); + } + } catch (Exception e) { + status.set(false); + log.debug(e.getMessage(), e); + } finally { + if (!status.getAndSet(true)) { + throw new RuntimeException("container hasn't come up yet"); + } + } + }); + return status; + } + ); + } + + @Override + public void waitUntilReady() { + waitUntilReady(target); + } +} diff --git a/modules/yugabytedb/src/main/java/org/testcontainers/containers/strategy/YugabyteDBYSQLWaitStrategy.java b/modules/yugabytedb/src/main/java/org/testcontainers/containers/strategy/YugabyteDBYSQLWaitStrategy.java new file mode 100644 index 00000000000..4cb5349dc5b --- /dev/null +++ b/modules/yugabytedb/src/main/java/org/testcontainers/containers/strategy/YugabyteDBYSQLWaitStrategy.java @@ -0,0 +1,63 @@ +package org.testcontainers.containers.strategy; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.testcontainers.containers.YugabyteDBYSQLContainer; +import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.concurrent.TimeUnit; + +import static org.rnorth.ducttape.unreliables.Unreliables.retryUntilSuccess; + +/** + * Custom wait strategy for YSQL API. + * + *

+ * Though we can either use HTTP or PORT based wait strategy, when we create a custom + * database/role, it gets executed asynchronously. As the wait on container.start() on a + * specific port wouldn't fully guarantee the custom object execution. It's better to + * check the DB status with this way with a smoke test query that uses the underlying + * custom objects and wait for the operation to complete. + *

+ */ +@RequiredArgsConstructor +@Slf4j +public final class YugabyteDBYSQLWaitStrategy extends AbstractWaitStrategy { + + private final WaitStrategyTarget target; + + private static final String YSQL_EXTENDED_PROBE = + "CREATE TABLE IF NOT EXISTS YB_SAMPLE(k int, v int, primary key(k, v))"; + + private static final String YSQL_EXTENDED_PROBE_DROP_TABLE = "DROP TABLE IF EXISTS YB_SAMPLE"; + + @Override + public void waitUntilReady(WaitStrategyTarget target) { + YugabyteDBYSQLContainer container = (YugabyteDBYSQLContainer) target; + retryUntilSuccess( + (int) startupTimeout.getSeconds(), + TimeUnit.SECONDS, + () -> { + getRateLimiter() + .doWhenReady(() -> { + try (Connection con = container.createConnection(""); Statement stmt = con.createStatement()) { + stmt.execute(YSQL_EXTENDED_PROBE); + stmt.execute(YSQL_EXTENDED_PROBE_DROP_TABLE); + } catch (SQLException ex) { + throw new RuntimeException(ex); + } + }); + return true; + } + ); + } + + @Override + public void waitUntilReady() { + waitUntilReady(target); + } +} diff --git a/modules/yugabytedb/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/modules/yugabytedb/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 00000000000..26e372d51ab --- /dev/null +++ b/modules/yugabytedb/src/main/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.testcontainers.containers.YugabyteDBYSQLContainerProvider diff --git a/modules/yugabytedb/src/test/java/org/testcontainers/jdbc/yugabytedb/YugabyteDBYSQLJDBCDriverTest.java b/modules/yugabytedb/src/test/java/org/testcontainers/jdbc/yugabytedb/YugabyteDBYSQLJDBCDriverTest.java new file mode 100644 index 00000000000..37129e8f899 --- /dev/null +++ b/modules/yugabytedb/src/test/java/org/testcontainers/jdbc/yugabytedb/YugabyteDBYSQLJDBCDriverTest.java @@ -0,0 +1,23 @@ +package org.testcontainers.jdbc.yugabytedb; + +import org.testcontainers.jdbc.AbstractJDBCDriverTest; + +import java.util.Arrays; +import java.util.EnumSet; + +/** + * YugabyteDB YSQL API JDBC connectivity driver test class + */ +class YugabyteDBYSQLJDBCDriverTest extends AbstractJDBCDriverTest { + + public static Iterable data() { + return Arrays.asList( + new Object[][] { + { + "jdbc:tc:yugabyte://hostname/yugabyte?user=yugabyte&password=yugabyte", + EnumSet.noneOf(Options.class), + }, + } + ); + } +} diff --git a/modules/yugabytedb/src/test/java/org/testcontainers/junit/yugabytedb/YugabyteDBYCQLTest.java b/modules/yugabytedb/src/test/java/org/testcontainers/junit/yugabytedb/YugabyteDBYCQLTest.java new file mode 100644 index 00000000000..24e6b2c24dd --- /dev/null +++ b/modules/yugabytedb/src/test/java/org/testcontainers/junit/yugabytedb/YugabyteDBYCQLTest.java @@ -0,0 +1,140 @@ +package org.testcontainers.junit.yugabytedb; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.YugabyteDBYCQLContainer; +import org.testcontainers.utility.DockerImageName; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * YugabyteDB YCQL API unit test class + */ +class YugabyteDBYCQLTest { + + private static final String IMAGE_NAME = "yugabytedb/yugabyte:2.14.4.0-b26"; + + private static final String IMAGE_NAME_2_18 = "yugabytedb/yugabyte:2.18.3.0-b75"; + + private static final DockerImageName YBDB_TEST_IMAGE = DockerImageName.parse(IMAGE_NAME); + + @Test + void testSmoke() { + try ( + // creatingYCQLContainer { + final YugabyteDBYCQLContainer ycqlContainer = new YugabyteDBYCQLContainer( + "yugabytedb/yugabyte:2.14.4.0-b26" + ) + .withUsername("cassandra") + .withPassword("cassandra") + // } + ) { + ycqlContainer.start(); + assertThat(performQuery(ycqlContainer, "SELECT release_version FROM system.local").wasApplied()) + .as("A sample test query succeeds") + .isTrue(); + } + } + + @Test + void testCustomKeyspace() { + String key = "random"; + try ( + final YugabyteDBYCQLContainer ycqlContainer = new YugabyteDBYCQLContainer(YBDB_TEST_IMAGE) + .withKeyspaceName(key) + .withUsername("cassandra") + .withPassword("cassandra") + ) { + ycqlContainer.start(); + assertThat( + performQuery( + ycqlContainer, + "SELECT keyspace_name FROM system_schema.keyspaces where keyspace_name='" + key + "'" + ) + .one() + .getString(0) + ) + .as("Custom keyspace creation succeeds") + .isEqualTo(key); + } + } + + @Test + void testAuthenticationEnabled() { + String role = "random"; + try ( + final YugabyteDBYCQLContainer ycqlContainer = new YugabyteDBYCQLContainer(YBDB_TEST_IMAGE) + .withUsername(role) + .withPassword(role) + ) { + ycqlContainer.start(); + assertThat( + performQuery(ycqlContainer, "SELECT role FROM system_auth.roles where role='" + role + "'") + .one() + .getString(0) + ) + .as("Keyspace login with authentication enabled succeeds") + .isEqualTo(role); + } + } + + @Test + void testAuthenticationDisabled() { + try ( + final YugabyteDBYCQLContainer ycqlContainer = new YugabyteDBYCQLContainer(YBDB_TEST_IMAGE) + .withPassword("cassandra") + .withUsername("cassandra") + ) { + ycqlContainer.start(); + assertThat(performQuery(ycqlContainer, "SELECT release_version FROM system.local").wasApplied()) + .as("Keyspace login with authentication disabled succeeds") + .isTrue(); + } + } + + @Test + void testInitScript() { + String key = "random"; + try ( + final YugabyteDBYCQLContainer ycqlContainer = new YugabyteDBYCQLContainer(YBDB_TEST_IMAGE) + .withKeyspaceName(key) + .withUsername(key) + .withPassword(key) + .withInitScript("init/init_yql.sql") + ) { + ycqlContainer.start(); + ResultSet output = performQuery(ycqlContainer, "SELECT greet FROM random.dsql"); + assertThat(output.wasApplied()).as("Statements from a custom script execution succeeds").isTrue(); + assertThat(output.one().getString(0)).as("A record match succeeds").isEqualTo("Hello DSQL"); + } + } + + @Test + void shouldStartWhenContainerIpIsUsedInWaitStrategy() { + try ( + final YugabyteDBYCQLContainer ycqlContainer = new YugabyteDBYCQLContainer(IMAGE_NAME_2_18) + .withUsername("cassandra") + .withPassword("cassandra") + ) { + ycqlContainer.start(); + boolean isQueryExecuted = performQuery(ycqlContainer, "SELECT release_version FROM system.local") + .wasApplied(); + assertThat(isQueryExecuted).isTrue(); + } + } + + private ResultSet performQuery(YugabyteDBYCQLContainer ycqlContainer, String cql) { + try ( + CqlSession session = CqlSession + .builder() + .withKeyspace(ycqlContainer.getKeyspace()) + .withAuthCredentials(ycqlContainer.getUsername(), ycqlContainer.getPassword()) + .withLocalDatacenter(ycqlContainer.getLocalDc()) + .addContactPoint(ycqlContainer.getContactPoint()) + .build() + ) { + return session.execute(cql); + } + } +} diff --git a/modules/yugabytedb/src/test/java/org/testcontainers/junit/yugabytedb/YugabyteDBYSQLTest.java b/modules/yugabytedb/src/test/java/org/testcontainers/junit/yugabytedb/YugabyteDBYSQLTest.java new file mode 100644 index 00000000000..04d1448cfba --- /dev/null +++ b/modules/yugabytedb/src/test/java/org/testcontainers/junit/yugabytedb/YugabyteDBYSQLTest.java @@ -0,0 +1,112 @@ +package org.testcontainers.junit.yugabytedb; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.YugabyteDBYSQLContainer; +import org.testcontainers.db.AbstractContainerDatabaseTest; +import org.testcontainers.utility.DockerImageName; + +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * YugabyteDB YSQL API unit test class + */ +class YugabyteDBYSQLTest extends AbstractContainerDatabaseTest { + + private static final String IMAGE_NAME = "yugabytedb/yugabyte:2.14.4.0-b26"; + + private static final DockerImageName YBDB_TEST_IMAGE = DockerImageName.parse(IMAGE_NAME); + + @Test + void testSmoke() throws SQLException { + try ( + // creatingYSQLContainer { + final YugabyteDBYSQLContainer ysqlContainer = new YugabyteDBYSQLContainer( + "yugabytedb/yugabyte:2.14.4.0-b26" + ) + // } + ) { + ysqlContainer.start(); + assertThat(performQuery(ysqlContainer, "SELECT 1").getInt(1)) + .as("A sample test query succeeds") + .isEqualTo(1); + } + } + + @Test + void testCustomDatabase() throws SQLException { + String key = "random"; + try ( + final YugabyteDBYSQLContainer ysqlContainer = new YugabyteDBYSQLContainer(YBDB_TEST_IMAGE) + .withDatabaseName(key) + ) { + ysqlContainer.start(); + assertThat(performQuery(ysqlContainer, "SELECT 1").getInt(1)) + .as("A test query on a custom database succeeds") + .isEqualTo(1); + } + } + + @Test + void testInitScript() throws SQLException { + try ( + final YugabyteDBYSQLContainer ysqlContainer = new YugabyteDBYSQLContainer(YBDB_TEST_IMAGE) + .withInitScript("init/init_yql.sql") + ) { + ysqlContainer.start(); + assertThat(performQuery(ysqlContainer, "SELECT greet FROM dsql").getString(1)) + .as("A record match succeeds") + .isEqualTo("Hello DSQL"); + } + } + + @Test + void testWithAdditionalUrlParamInJdbcUrl() { + try ( + final YugabyteDBYSQLContainer ysqlContainer = new YugabyteDBYSQLContainer(YBDB_TEST_IMAGE) + .withUrlParam("sslmode", "disable") + .withUrlParam("application_name", "yugabyte") + ) { + ysqlContainer.start(); + String jdbcUrl = ysqlContainer.getJdbcUrl(); + assertThat(jdbcUrl) + .contains("?") + .contains("&") + .contains("sslmode=disable") + .contains("application_name=yugabyte") + .as("A JDBC connection string with additional parameter validation succeeds"); + } + } + + @Test + void testWithCustomRole() throws SQLException { + try ( + final YugabyteDBYSQLContainer ysqlContainer = new YugabyteDBYSQLContainer(YBDB_TEST_IMAGE) + .withDatabaseName("yugabyte") + .withPassword("yugabyte") + .withUsername("yugabyte") + ) { + ysqlContainer.start(); + assertThat(performQuery(ysqlContainer, "SELECT 1").getInt(1)) + .as("A sample test query with a custom role succeeds") + .isEqualTo(1); + } + } + + @Test + void testWaitStrategy() throws SQLException { + try (final YugabyteDBYSQLContainer ysqlContainer = new YugabyteDBYSQLContainer(YBDB_TEST_IMAGE)) { + ysqlContainer.start(); + assertThat(performQuery(ysqlContainer, "SELECT 1").getInt(1)) + .as("A sample test query succeeds") + .isEqualTo(1); + boolean tableExists = performQuery( + ysqlContainer, + "SELECT EXISTS (SELECT FROM pg_tables WHERE tablename = 'YB_SAMPLE')" + ) + .getBoolean(1); + assertThat(tableExists).as("yb_sample table does not exists").isFalse(); + } + } +} diff --git a/modules/yugabytedb/src/test/resources/init/init_yql.sql b/modules/yugabytedb/src/test/resources/init/init_yql.sql new file mode 100644 index 00000000000..31cbaa6a758 --- /dev/null +++ b/modules/yugabytedb/src/test/resources/init/init_yql.sql @@ -0,0 +1,5 @@ +CREATE TABLE dsql( + greet text primary key +); + +INSERT INTO dsql (greet) VALUES ('Hello DSQL'); diff --git a/modules/yugabytedb/src/test/resources/logback-test.xml b/modules/yugabytedb/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/modules/yugabytedb/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/mvnw b/mvnw deleted file mode 100755 index 6ecc150ae0a..00000000000 --- a/mvnw +++ /dev/null @@ -1,236 +0,0 @@ -#!/bin/sh -# ---------------------------------------------------------------------------- -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# ---------------------------------------------------------------------------- - -# ---------------------------------------------------------------------------- -# Maven2 Start Up Batch script -# -# Required ENV vars: -# ------------------ -# JAVA_HOME - location of a JDK home dir -# -# Optional ENV vars -# ----------------- -# M2_HOME - location of maven2's installed home dir -# MAVEN_OPTS - parameters passed to the Java VM when running Maven -# e.g. to debug Maven itself, use -# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 -# MAVEN_SKIP_RC - flag to disable loading of mavenrc files -# ---------------------------------------------------------------------------- - -if [ -z "$MAVEN_SKIP_RC" ] ; then - - if [ -f /etc/mavenrc ] ; then - . /etc/mavenrc - fi - - if [ -f "$HOME/.mavenrc" ] ; then - . "$HOME/.mavenrc" - fi - -fi - -# OS specific support. $var _must_ be set to either true or false. -cygwin=false; -darwin=false; -mingw=false -case "`uname`" in - CYGWIN*) cygwin=true ;; - MINGW*) mingw=true;; - Darwin*) darwin=true - # - # Look for the Apple JDKs first to preserve the existing behaviour, and then look - # for the new JDKs provided by Oracle. - # - if [ -z "$JAVA_HOME" ] && [ -L /System/Library/Frameworks/JavaVM.framework/Versions/CurrentJDK ] ; then - # - # Apple JDKs - # - export JAVA_HOME=/System/Library/Frameworks/JavaVM.framework/Versions/CurrentJDK/Home - fi - - if [ -z "$JAVA_HOME" ] && [ -L /System/Library/Java/JavaVirtualMachines/CurrentJDK ] ; then - # - # Apple JDKs - # - export JAVA_HOME=/System/Library/Java/JavaVirtualMachines/CurrentJDK/Contents/Home - fi - - if [ -z "$JAVA_HOME" ] && [ -L "/Library/Java/JavaVirtualMachines/CurrentJDK" ] ; then - # - # Oracle JDKs - # - export JAVA_HOME=/Library/Java/JavaVirtualMachines/CurrentJDK/Contents/Home - fi - - if [ -z "$JAVA_HOME" ] && [ -x "/usr/libexec/java_home" ]; then - # - # Apple JDKs - # - export JAVA_HOME=`/usr/libexec/java_home` - fi - ;; -esac - -if [ -z "$JAVA_HOME" ] ; then - if [ -r /etc/gentoo-release ] ; then - JAVA_HOME=`java-config --jre-home` - fi -fi - -if [ -z "$M2_HOME" ] ; then - ## resolve links - $0 may be a link to maven's home - PRG="$0" - - # need this for relative symlinks - while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG="`dirname "$PRG"`/$link" - fi - done - - saveddir=`pwd` - - M2_HOME=`dirname "$PRG"`/.. - - # make it fully qualified - M2_HOME=`cd "$M2_HOME" && pwd` - - cd "$saveddir" - # echo Using m2 at $M2_HOME -fi - -# For Cygwin, ensure paths are in UNIX format before anything is touched -if $cygwin ; then - [ -n "$M2_HOME" ] && - M2_HOME=`cygpath --unix "$M2_HOME"` - [ -n "$JAVA_HOME" ] && - JAVA_HOME=`cygpath --unix "$JAVA_HOME"` - [ -n "$CLASSPATH" ] && - CLASSPATH=`cygpath --path --unix "$CLASSPATH"` -fi - -# For Migwn, ensure paths are in UNIX format before anything is touched -if $mingw ; then - [ -n "$M2_HOME" ] && - M2_HOME="`(cd "$M2_HOME"; pwd)`" - [ -n "$JAVA_HOME" ] && - JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" - # TODO classpath? -fi - -if [ -z "$JAVA_HOME" ]; then - javaExecutable="`which javac`" - if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then - # readlink(1) is not available as standard on Solaris 10. - readLink=`which readlink` - if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then - if $darwin ; then - javaHome="`dirname \"$javaExecutable\"`" - javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" - else - javaExecutable="`readlink -f \"$javaExecutable\"`" - fi - javaHome="`dirname \"$javaExecutable\"`" - javaHome=`expr "$javaHome" : '\(.*\)/bin'` - JAVA_HOME="$javaHome" - export JAVA_HOME - fi - fi -fi - -if [ -z "$JAVACMD" ] ; then - if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - else - JAVACMD="`which java`" - fi -fi - -if [ ! -x "$JAVACMD" ] ; then - echo "Error: JAVA_HOME is not defined correctly." >&2 - echo " We cannot execute $JAVACMD" >&2 - exit 1 -fi - -if [ -z "$JAVA_HOME" ] ; then - echo "Warning: JAVA_HOME environment variable is not set." -fi - -CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher - -# traverses directory structure from process work directory to filesystem root -# first directory with .mvn subdirectory is considered project base directory -find_maven_basedir() { - local basedir=$(pwd) - local wdir=$(pwd) - while [ "$wdir" != '/' ] ; do - if [ -d "$wdir"/.mvn ] ; then - basedir=$wdir - break - fi - wdir=$(cd "$wdir/.."; pwd) - done - echo "${basedir}" -} - -# concatenates all lines of a file -concat_lines() { - if [ -f "$1" ]; then - echo "$(tr -s '\n' ' ' < "$1")" - fi -} - -export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-$(find_maven_basedir)} -MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" - -# For Cygwin, switch paths to Windows format before running java -if $cygwin; then - [ -n "$M2_HOME" ] && - M2_HOME=`cygpath --path --windows "$M2_HOME"` - [ -n "$JAVA_HOME" ] && - JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` - [ -n "$CLASSPATH" ] && - CLASSPATH=`cygpath --path --windows "$CLASSPATH"` - [ -n "$MAVEN_PROJECTBASEDIR" ] && - MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` -fi - -# Provide a "standardized" way to retrieve the CLI args that will -# work with both Windows and non-Windows executions. -MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" -export MAVEN_CMD_LINE_ARGS - -WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain - -# avoid using MAVEN_CMD_LINE_ARGS below since that would loose parameter escaping in $@ -exec "$JAVACMD" \ - $MAVEN_OPTS \ - -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ - "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ - ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd deleted file mode 100644 index 8bb82754165..00000000000 --- a/mvnw.cmd +++ /dev/null @@ -1,146 +0,0 @@ -@REM ---------------------------------------------------------------------------- -@REM Licensed to the Apache Software Foundation (ASF) under one -@REM or more contributor license agreements. See the NOTICE file -@REM distributed with this work for additional information -@REM regarding copyright ownership. The ASF licenses this file -@REM to you under the Apache License, Version 2.0 (the -@REM "License"); you may not use this file except in compliance -@REM with the License. You may obtain a copy of the License at -@REM -@REM http://www.apache.org/licenses/LICENSE-2.0 -@REM -@REM Unless required by applicable law or agreed to in writing, -@REM software distributed under the License is distributed on an -@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -@REM KIND, either express or implied. See the License for the -@REM specific language governing permissions and limitations -@REM under the License. -@REM ---------------------------------------------------------------------------- - -@REM ---------------------------------------------------------------------------- -@REM Maven2 Start Up Batch script -@REM -@REM Required ENV vars: -@REM JAVA_HOME - location of a JDK home dir -@REM -@REM Optional ENV vars -@REM M2_HOME - location of maven2's installed home dir -@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands -@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending -@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven -@REM e.g. to debug Maven itself, use -@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 -@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files -@REM ---------------------------------------------------------------------------- - -@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' -@echo off -@REM enable echoing my setting MAVEN_BATCH_ECHO to 'on' -@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% - -@REM set %HOME% to equivalent of $HOME -if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") - -@REM Execute a user defined script before this one -if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre -@REM check for pre script, once with legacy .bat ending and once with .cmd ending -if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" -if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" -:skipRcPre - -@setlocal - -set ERROR_CODE=0 - -@REM To isolate internal variables from possible post scripts, we use another setlocal -@setlocal - -@REM ==== START VALIDATION ==== -if not "%JAVA_HOME%" == "" goto OkJHome - -echo. -echo Error: JAVA_HOME not found in your environment. >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. -goto error - -:OkJHome -if exist "%JAVA_HOME%\bin\java.exe" goto init - -echo. -echo Error: JAVA_HOME is set to an invalid directory. >&2 -echo JAVA_HOME = "%JAVA_HOME%" >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. -goto error - -@REM ==== END VALIDATION ==== - -:init - -set MAVEN_CMD_LINE_ARGS=%MAVEN_CONFIG% %* - -@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". -@REM Fallback to current working directory if not found. - -set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% -IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir - -set EXEC_DIR=%CD% -set WDIR=%EXEC_DIR% -:findBaseDir -IF EXIST "%WDIR%"\.mvn goto baseDirFound -cd .. -IF "%WDIR%"=="%CD%" goto baseDirNotFound -set WDIR=%CD% -goto findBaseDir - -:baseDirFound -set MAVEN_PROJECTBASEDIR=%WDIR% -cd "%EXEC_DIR%" -goto endDetectBaseDir - -:baseDirNotFound -set MAVEN_PROJECTBASEDIR=%EXEC_DIR% -cd "%EXEC_DIR%" - -:endDetectBaseDir - -IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig - -@setlocal EnableExtensions EnableDelayedExpansion -for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a -@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% - -:endReadAdditionalConfig - -SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" - -set WRAPPER_JAR=""%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"" -set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain - -# avoid using MAVEN_CMD_LINE_ARGS below since that would loose parameter escaping in %* -%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* -if ERRORLEVEL 1 goto error -goto end - -:error -set ERROR_CODE=1 - -:end -@endlocal & set ERROR_CODE=%ERROR_CODE% - -if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost -@REM check for post script, once with legacy .bat ending and once with .cmd ending -if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" -if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" -:skipRcPost - -@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' -if "%MAVEN_BATCH_PAUSE%" == "on" pause - -if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% - -exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml deleted file mode 100644 index ed8598949a9..00000000000 --- a/pom.xml +++ /dev/null @@ -1,199 +0,0 @@ - - - 4.0.0 - - org.testcontainers - testcontainers-parent - 1.2.1-SNAPSHOT - - pom - - TestContainers Parent POM - - Isolated container management for Java code testing - - https://github.com/testcontainers/testcontainers-java - - - MIT - http://opensource.org/licenses/MIT - - - - - rnorth - Richard North - rich.north@gmail.com - - - swissarmykirpan - Gurpreet Sohal - gurpreet@gurpreetsohal.com - - - alexboldt - Alex Boldt - boldtalex@gmail.com - - - rpozarickij - Robert Požarickij - robert.pozarickij@gmail.com - - - krystiannowak - Krystian Nowak - krystian.nowak@gmail.com - - - mbaechler - Matthieu Baechler - matthieu.baechler@gmail.com - - - v-schulz - Viktor Schulz - vschulz@mail.uni-mannheim.de - - - pcornish - Pete Cornish - outofcoffee@gmail.com - - - mgonzalez - Miguel Gonzalez Sanchez - miguel-gonzalez@gmx.de - - - - - - junit - junit - 4.12 - - - - - org.projectlombok - lombok - 1.16.6 - provided - - - - - org.slf4j - slf4j-api - 1.7.7 - - - ch.qos.logback - logback-classic - 1.1.2 - test - - - org.rnorth.visible-assertions - visible-assertions - 1.0.5 - test - - - - - - - maven-compiler-plugin - 3.1 - - 1.8 - 1.8 - - - - maven-release-plugin - 2.5.1 - - false - true - - - - maven-source-plugin - 2.4 - - - attach-sources - - jar - - - - - - maven-javadoc-plugin - 2.10.3 - - - attach-javadocs - - jar - - - - - - maven-scm-plugin - 1.9.4 - - testcontainers-${project.version} - - - - - - - UTF-8 - - - - core - modules/jdbc - modules/mysql - modules/postgresql - modules/selenium - modules/nginx - modules/mariadb - modules/jdbc-test - - - - - proprietary-deps - - modules/oracle-xe - modules/virtuoso - - - - shade - - - - - scm:git:https://github.com/testcontainers/testcontainers-java.git - scm:git:git@github.com:testcontainers/testcontainers-java.git - https://github.com/testcontainers/testcontainers-java - HEAD - - - - - bintray - https://api.bintray.com/maven/richnorth/maven/test-containers - - - diff --git a/release.sh b/release.sh deleted file mode 100755 index 600be040400..00000000000 --- a/release.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -set -eou pipefail - -mvn release:prepare -Pproprietary-deps - -mvn release:perform -Pproprietary-deps \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000000..f77d057b599 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +mkdocs==1.3.0 +mkdocs-codeinclude-plugin==0.2.0 +mkdocs-material==8.1.3 +mkdocs-markdownextradata-plugin==0.2.5 diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 00000000000..cc1923a40b1 --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +3.8 diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000000..374dbbb00dc --- /dev/null +++ b/settings.gradle @@ -0,0 +1,60 @@ +buildscript { + repositories { + maven { + url "https://plugins.gradle.org/m2/" + } + } + dependencies { + classpath "com.gradle:develocity-gradle-plugin:4.4.1" + classpath "com.gradle:common-custom-user-data-gradle-plugin:2.6.0" + classpath "org.gradle.toolchains:foojay-resolver:0.8.0" + } +} + +apply plugin: 'com.gradle.develocity' +apply plugin: "com.gradle.common-custom-user-data-gradle-plugin" +apply plugin: "org.gradle.toolchains.foojay-resolver-convention" + +rootProject.name = 'testcontainers-java' + +include "bom" + +include "testcontainers" +project(':testcontainers').projectDir = "$rootDir/core" as File + +file('modules').eachDir { dir -> + include "testcontainers-${dir.name}" + project(":testcontainers-${dir.name}").projectDir = dir +} + +include ':docs:examples:junit5:redis' +include ':docs:examples:spock:redis' + +include 'test-support' + +ext.isCI = System.getenv("CI") != null + +buildCache { + local { + enabled = !isCI + } + remote(develocity.buildCache) { + enabled = true + // Check access key presence to avoid build cache errors on PR builds when access key is not present + push = isCI && !System.getenv("READ_ONLY_REMOTE_GRADLE_CACHE") && System.getenv("DEVELOCITY_ACCESS_KEY") != null + } +} + +develocity { + server = "https://community.develocity.cloud" + projectId = "testcontainers" + buildScan { + publishing.onlyIf { + it.authenticated + } + uploadInBackground = !isCI + obfuscation { + ipAddresses { it.collect { "0.0.0.0" } } + } + } +} diff --git a/shade-test/README.md b/shade-test/README.md deleted file mode 100644 index 1d9f4cd22b9..00000000000 --- a/shade-test/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# shade-test - -This module contains what are effectively 'integration tests' for the shaded testcontainers core JAR. - -These test modules should do things that will break without shading being used - i.e. depend upon incompatible versions - of core's dependencies. \ No newline at end of file diff --git a/shade-test/clashing-deps-jackson/pom.xml b/shade-test/clashing-deps-jackson/pom.xml deleted file mode 100644 index cc4de2625b2..00000000000 --- a/shade-test/clashing-deps-jackson/pom.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - shade-test - org.testcontainers - 1.2.1-SNAPSHOT - ../pom.xml - - 4.0.0 - - clashing-deps-jackson - - - - redis.clients - jedis - 2.8.0 - - - com.fasterxml.jackson.core - jackson-databind - 2.6.3 - - - \ No newline at end of file diff --git a/shade-test/clashing-deps-jackson/src/main/java/clashingdeps/jackson/cache/RedisJacksonBackedCache.java b/shade-test/clashing-deps-jackson/src/main/java/clashingdeps/jackson/cache/RedisJacksonBackedCache.java deleted file mode 100644 index 1024eb49517..00000000000 --- a/shade-test/clashing-deps-jackson/src/main/java/clashingdeps/jackson/cache/RedisJacksonBackedCache.java +++ /dev/null @@ -1,54 +0,0 @@ -package clashingdeps.jackson.cache; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import redis.clients.jedis.Jedis; - -import java.io.IOException; -import java.util.Optional; - -/** - * An implementation of {@link Cache} that stores data in Redis. - * - * This implementation uses Jackson for JSON serialization/deserialization. - */ -public class RedisJacksonBackedCache implements Cache { - - private final Jedis jedis; - private final String cacheName; - private final ObjectMapper objectMapper; - - public RedisJacksonBackedCache(Jedis jedis, String cacheName) { - this.jedis = jedis; - this.cacheName = cacheName; - this.objectMapper = new ObjectMapper(); - } - - @Override - public void put(String key, Object value) { - String jsonValue = null; - try { - jsonValue = objectMapper.writeValueAsString(value); - } catch (JsonProcessingException e) { - e.printStackTrace(); - // Do something with this in reality - } - this.jedis.hset(this.cacheName, key, jsonValue); - } - - @Override - public Optional get(String key, Class expectedClass) { - String foundJson = this.jedis.hget(this.cacheName, key); - - if (foundJson == null) { - return Optional.empty(); - } - - try { - return Optional.of(objectMapper.readValue(foundJson, expectedClass)); - } catch (IOException e) { - return Optional.empty(); - // Do something with this in reality - } - } -} diff --git a/shade-test/clashing-deps-jackson/src/test/java/RedisJacksonBackedCacheTest.java b/shade-test/clashing-deps-jackson/src/test/java/RedisJacksonBackedCacheTest.java deleted file mode 100644 index 8271233bb98..00000000000 --- a/shade-test/clashing-deps-jackson/src/test/java/RedisJacksonBackedCacheTest.java +++ /dev/null @@ -1,64 +0,0 @@ -import clashingdeps.jackson.cache.Cache; -import clashingdeps.jackson.cache.RedisJacksonBackedCache; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.rnorth.ducttape.unreliables.Unreliables; -import org.testcontainers.containers.GenericContainer; -import redis.clients.jedis.Jedis; - -import java.util.Optional; -import java.util.concurrent.TimeUnit; - -import static org.rnorth.visibleassertions.VisibleAssertions.*; - -/** - * Integration test for Redis-backed cache implementation. - */ -public class RedisJacksonBackedCacheTest { - - @Rule - public GenericContainer redis = new GenericContainer("redis:3.0.6") - .withExposedPorts(6379) - .waitingFor(new RedisWaitStrategy()); - private Cache cache; - - @Before - public void setUp() throws Exception { - Jedis jedis = new Jedis(redis.getContainerIpAddress(), redis.getMappedPort(6379)); - - cache = new RedisJacksonBackedCache(jedis, "test"); - } - - @Test - public void testFindingAnInsertedValue() { - cache.put("foo", "FOO"); - Optional foundObject = cache.get("foo", String.class); - - assertTrue("When an object in the cache is retrieved, it can be found", - foundObject.isPresent()); - assertEquals("When we put a String in to the cache and retrieve it, the value is the same", - "FOO", - foundObject.get()); - } - - @Test - public void testNotFindingAValueThatWasNotInserted() { - Optional foundObject = cache.get("bar", String.class); - - assertFalse("When an object that's not in the cache is retrieved, nothing is found", - foundObject.isPresent()); - } - - private class RedisWaitStrategy extends GenericContainer.AbstractWaitStrategy { - @Override - protected void waitUntilReady() { - //noinspection LoopStatementThatDoesntLoop - Unreliables.retryUntilSuccess(10, TimeUnit.SECONDS, () -> - { - Jedis jedis = new Jedis(redis.getContainerIpAddress(), redis.getMappedPort(6379)); - return jedis.ping(); - }); - } - } -} diff --git a/shade-test/clashing-deps-jackson/src/test/resources/logback-test.xml b/shade-test/clashing-deps-jackson/src/test/resources/logback-test.xml deleted file mode 100644 index d12e392071a..00000000000 --- a/shade-test/clashing-deps-jackson/src/test/resources/logback-test.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - %d{HH:mm:ss.SSS} %-5level %logger - %msg%n - - - - - - - - - - - - - - - - - - - - PROFILER - DENY - - \ No newline at end of file diff --git a/shade-test/clashing-deps-jersey/pom.xml b/shade-test/clashing-deps-jersey/pom.xml deleted file mode 100644 index 84768aa2441..00000000000 --- a/shade-test/clashing-deps-jersey/pom.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - shade-test - org.testcontainers - 1.2.1-SNAPSHOT - ../pom.xml - - 4.0.0 - - clashing-deps-jersey - - - - com.sun.jersey - jersey-client - 1.19 - - - \ No newline at end of file diff --git a/shade-test/clashing-deps-jersey/src/test/java/OldJerseyClientTest.java b/shade-test/clashing-deps-jersey/src/test/java/OldJerseyClientTest.java deleted file mode 100644 index cffc275240d..00000000000 --- a/shade-test/clashing-deps-jersey/src/test/java/OldJerseyClientTest.java +++ /dev/null @@ -1,23 +0,0 @@ -import com.sun.jersey.api.client.Client; -import org.junit.Rule; -import org.junit.Test; -import org.testcontainers.containers.GenericContainer; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; - -/** - * Created by rnorth on 14/01/2016. - */ -public class OldJerseyClientTest { - - @Rule - public GenericContainer httpd = new GenericContainer("httpd:2.4").withExposedPorts(80); - - @Test - public void clientRequestTest() { - Client client = Client.create(); - String s = client.resource("http://" + httpd.getContainerIpAddress() + ":" + httpd.getMappedPort(80)).get(String.class); - - assertTrue("httpd is displaying its default placeholder page", s.contains("It works")); - } -} diff --git a/shade-test/clashing-deps-jersey/src/test/resources/logback-test.xml b/shade-test/clashing-deps-jersey/src/test/resources/logback-test.xml deleted file mode 100644 index d12e392071a..00000000000 --- a/shade-test/clashing-deps-jersey/src/test/resources/logback-test.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - %d{HH:mm:ss.SSS} %-5level %logger - %msg%n - - - - - - - - - - - - - - - - - - - - PROFILER - DENY - - \ No newline at end of file diff --git a/shade-test/pom.xml b/shade-test/pom.xml deleted file mode 100644 index c8ef9e4a8d1..00000000000 --- a/shade-test/pom.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - testcontainers-parent - org.testcontainers - 1.2.1-SNAPSHOT - ../pom.xml - - 4.0.0 - - shade-test - pom - - - clashing-deps-jackson - clashing-deps-jersey - service-lookup-dropwizard - - - - - org.testcontainers - testcontainers - ${project.version} - - - org.rnorth.visible-assertions - visible-assertions - 1.0.5 - - - \ No newline at end of file diff --git a/shade-test/service-lookup-dropwizard/pom.xml b/shade-test/service-lookup-dropwizard/pom.xml deleted file mode 100644 index 09f318f74c1..00000000000 --- a/shade-test/service-lookup-dropwizard/pom.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - shade-test - org.testcontainers - 1.2.1-SNAPSHOT - ../pom.xml - - 4.0.0 - - service-lookup-dropwizard - - - io.dropwizard - dropwizard-core - 0.9.2 - - - io.dropwizard - dropwizard-testing - 0.9.2 - - - - - \ No newline at end of file diff --git a/shade-test/service-lookup-dropwizard/src/main/java/JerseyResource.java b/shade-test/service-lookup-dropwizard/src/main/java/JerseyResource.java deleted file mode 100644 index 2ddde90110f..00000000000 --- a/shade-test/service-lookup-dropwizard/src/main/java/JerseyResource.java +++ /dev/null @@ -1,14 +0,0 @@ -import javax.ws.rs.GET; -import javax.ws.rs.Path; - -/** - * Created by rnorth on 18/02/2016. - */ -@Path("/foo") -public class JerseyResource { - - @GET - public String getMessage() { - return "bar"; - } -} diff --git a/shade-test/service-lookup-dropwizard/src/test/java/SimpleResourceTest.java b/shade-test/service-lookup-dropwizard/src/test/java/SimpleResourceTest.java deleted file mode 100644 index cfc7af4c364..00000000000 --- a/shade-test/service-lookup-dropwizard/src/test/java/SimpleResourceTest.java +++ /dev/null @@ -1,23 +0,0 @@ -import io.dropwizard.testing.junit.ResourceTestRule; -import org.junit.ClassRule; -import org.junit.Test; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; - -/** - * Created by rnorth on 18/02/2016. - */ -public class SimpleResourceTest { - - @ClassRule - public static final ResourceTestRule resources = ResourceTestRule.builder() - .addResource(new JerseyResource()) - .build(); - - @Test - public void simpleTest() { - assertEquals("the response from the resource", - "bar", - resources.client().target("/foo").request().get(String.class)); - } -} diff --git a/smoke-test/build.gradle b/smoke-test/build.gradle new file mode 100644 index 00000000000..781d6e87792 --- /dev/null +++ b/smoke-test/build.gradle @@ -0,0 +1,31 @@ +// empty build.gradle for dependabot +plugins { + id 'com.diffplug.spotless' version '6.22.0' apply false +} + +apply from: "$rootDir/../gradle/ci-support.gradle" + +subprojects { + apply plugin: "java" + apply from: "$rootDir/../gradle/spotless.gradle" + apply plugin: 'checkstyle' + + repositories { + mavenCentral() + } + + test { + defaultCharacterEncoding = "UTF-8" + testLogging { + displayGranularity 1 + showStackTraces = true + exceptionFormat = 'full' + events "STARTED", "PASSED", "FAILED", "SKIPPED" + } + } + + checkstyle { + toolVersion = "10.23.0" + configFile = rootProject.file('../config/checkstyle/checkstyle.xml') + } +} diff --git a/smoke-test/gradle/wrapper/gradle-wrapper.jar b/smoke-test/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000000..1b33c55baab Binary files /dev/null and b/smoke-test/gradle/wrapper/gradle-wrapper.jar differ diff --git a/smoke-test/gradle/wrapper/gradle-wrapper.properties b/smoke-test/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000..78cb6e16a49 --- /dev/null +++ b/smoke-test/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/smoke-test/gradlew b/smoke-test/gradlew new file mode 100755 index 00000000000..23d15a93670 --- /dev/null +++ b/smoke-test/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/smoke-test/gradlew.bat b/smoke-test/gradlew.bat new file mode 100644 index 00000000000..5eed7ee8452 --- /dev/null +++ b/smoke-test/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/smoke-test/settings.gradle b/smoke-test/settings.gradle new file mode 100644 index 00000000000..6334742441a --- /dev/null +++ b/smoke-test/settings.gradle @@ -0,0 +1,45 @@ +buildscript { + repositories { + maven { + url "https://plugins.gradle.org/m2/" + } + } + dependencies { + classpath "com.gradle.enterprise:com.gradle.enterprise.gradle.plugin:3.17.4" + classpath "com.gradle:common-custom-user-data-gradle-plugin:2.0.1" + } +} + +apply plugin: 'com.gradle.develocity' +apply plugin: "com.gradle.common-custom-user-data-gradle-plugin" + +rootProject.name = 'testcontainers-smoke-tests' + +includeBuild '..' + +// explicit include to allow Dependabot to autodiscover subprojects +include 'turbo-mode' + +ext.isCI = System.getenv("CI") != null + +buildCache { + local { + enabled = !isCI + } + remote(develocity.buildCache) { + push = isCI && !System.getenv("READ_ONLY_REMOTE_GRADLE_CACHE") && System.getenv("DEVELOCITY_ACCESS_KEY") + enabled = true + } +} + +develocity { + buildScan { + server = "https://ge.testcontainers.org/" + publishing.onlyIf { + it.authenticated + } + uploadInBackground = !isCI + capture.fileFingerprints = true + } + +} diff --git a/smoke-test/turbo-mode/build.gradle b/smoke-test/turbo-mode/build.gradle new file mode 100644 index 00000000000..fb3f09244fd --- /dev/null +++ b/smoke-test/turbo-mode/build.gradle @@ -0,0 +1,17 @@ +plugins { + id 'java' +} + +dependencies { + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + testImplementation 'ch.qos.logback:logback-classic:1.3.15' + testImplementation 'org.assertj:assertj-core:3.27.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.13.3' +} + +test { + useJUnitPlatform() + forkEvery = 1 + maxParallelForks = 4 +} diff --git a/smoke-test/turbo-mode/src/test/java/org/testcontainers/example/AbstractRedisContainer.java b/smoke-test/turbo-mode/src/test/java/org/testcontainers/example/AbstractRedisContainer.java new file mode 100644 index 00000000000..325660b34c4 --- /dev/null +++ b/smoke-test/turbo-mode/src/test/java/org/testcontainers/example/AbstractRedisContainer.java @@ -0,0 +1,21 @@ +package org.testcontainers.example; + +import org.testcontainers.containers.GenericContainer; + +import static org.assertj.core.api.Assertions.assertThat; + +class AbstractRedisContainer { + + private static final String REDIS_IMAGE = "redis:7.0.12-alpine"; + + void runRedisContainer() { + try ( + GenericContainer redis = new GenericContainer<>(REDIS_IMAGE) + .withExposedPorts(6379) + .withCreateContainerCmdModifier(cmd -> cmd.withName("tc-redis")) + ) { + redis.start(); + assertThat(redis.isRunning()).isTrue(); + } + } +} diff --git a/smoke-test/turbo-mode/src/test/java/org/testcontainers/example/RedisContainer1Test.java b/smoke-test/turbo-mode/src/test/java/org/testcontainers/example/RedisContainer1Test.java new file mode 100644 index 00000000000..4271e6b6caa --- /dev/null +++ b/smoke-test/turbo-mode/src/test/java/org/testcontainers/example/RedisContainer1Test.java @@ -0,0 +1,11 @@ +package org.testcontainers.example; + +import org.junit.jupiter.api.Test; + +class RedisContainer1Test extends AbstractRedisContainer { + + @Test + void testSimple() { + runRedisContainer(); + } +} diff --git a/smoke-test/turbo-mode/src/test/java/org/testcontainers/example/RedisContainer2Test.java b/smoke-test/turbo-mode/src/test/java/org/testcontainers/example/RedisContainer2Test.java new file mode 100644 index 00000000000..930196af5c0 --- /dev/null +++ b/smoke-test/turbo-mode/src/test/java/org/testcontainers/example/RedisContainer2Test.java @@ -0,0 +1,11 @@ +package org.testcontainers.example; + +import org.junit.jupiter.api.Test; + +class RedisContainer2Test extends AbstractRedisContainer { + + @Test + void testSimple() { + runRedisContainer(); + } +} diff --git a/smoke-test/turbo-mode/src/test/java/org/testcontainers/example/RedisContainer3Test.java b/smoke-test/turbo-mode/src/test/java/org/testcontainers/example/RedisContainer3Test.java new file mode 100644 index 00000000000..5afc93fd455 --- /dev/null +++ b/smoke-test/turbo-mode/src/test/java/org/testcontainers/example/RedisContainer3Test.java @@ -0,0 +1,11 @@ +package org.testcontainers.example; + +import org.junit.jupiter.api.Test; + +class RedisContainer3Test extends AbstractRedisContainer { + + @Test + void testSimple() { + runRedisContainer(); + } +} diff --git a/smoke-test/turbo-mode/src/test/java/org/testcontainers/example/RedisContainer4Test.java b/smoke-test/turbo-mode/src/test/java/org/testcontainers/example/RedisContainer4Test.java new file mode 100644 index 00000000000..b5a021bf049 --- /dev/null +++ b/smoke-test/turbo-mode/src/test/java/org/testcontainers/example/RedisContainer4Test.java @@ -0,0 +1,11 @@ +package org.testcontainers.example; + +import org.junit.jupiter.api.Test; + +class RedisContainer4Test extends AbstractRedisContainer { + + @Test + void testSimple() { + runRedisContainer(); + } +} diff --git a/smoke-test/turbo-mode/src/test/resources/logback-test.xml b/smoke-test/turbo-mode/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..83ef7a1a3ef --- /dev/null +++ b/smoke-test/turbo-mode/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/test-support/build.gradle b/test-support/build.gradle new file mode 100644 index 00000000000..56fcbd98a92 --- /dev/null +++ b/test-support/build.gradle @@ -0,0 +1,5 @@ +dependencies { + implementation 'junit:junit:4.13.2' + implementation 'org.slf4j:slf4j-api:2.0.17' + testImplementation 'org.assertj:assertj-core:3.27.4' +} diff --git a/test-support/src/main/java/org/testcontainers/testsupport/Flaky.java b/test-support/src/main/java/org/testcontainers/testsupport/Flaky.java new file mode 100644 index 00000000000..83e18bfa161 --- /dev/null +++ b/test-support/src/main/java/org/testcontainers/testsupport/Flaky.java @@ -0,0 +1,32 @@ +package org.testcontainers.testsupport; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation for test methods that should be retried in the event of failure. See {@link FlakyTestJUnit4RetryRule} for + * more details. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD }) +public @interface Flaky { + /** + * @return a URL for a GitHub issue where this flaky test can be discussed, and where actions to resolve it can be + * coordinated. + */ + String githubIssueUrl(); + + /** + * @return a date at which this should be reviewed, in {@link java.time.format.DateTimeFormatter#ISO_LOCAL_DATE} + * format (e.g. {@code 2020-12-03}). Now + 3 months is suggested. Once this date has passed, retries will no longer + * be applied. + */ + String reviewDate(); + + /** + * @return the total number of times to try running this test (default 3) + */ + int maxTries() default 3; +} diff --git a/test-support/src/main/java/org/testcontainers/testsupport/FlakyTestJUnit4RetryRule.java b/test-support/src/main/java/org/testcontainers/testsupport/FlakyTestJUnit4RetryRule.java new file mode 100644 index 00000000000..759e23ebbca --- /dev/null +++ b/test-support/src/main/java/org/testcontainers/testsupport/FlakyTestJUnit4RetryRule.java @@ -0,0 +1,103 @@ +package org.testcontainers.testsupport; + +import lombok.extern.slf4j.Slf4j; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.MultipleFailureException; +import org.junit.runners.model.Statement; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; + +/** + *

+ * JUnit 4 @Rule that implements retry for flaky tests (tests that suffer from sporadic random failures). + *

+ *

+ * This rule should be used in conjunction with the @{@link Flaky} annotation. When this Rule is applied to a test + * class, any test method with this annotation will be invoked up to 3 times or until it succeeds. + *

+ *

+ * Tests should not be marked @{@link Flaky} for a long period of time. Every usage should be + * accompanied by a GitHub issue URL, and should be subject to review at a suitable point in the (near) future. + * Should the review date pass without the test's instability being fixed, the retry behaviour will cease to have an + * effect and the test will be allowed to sporadically fail again. + *

+ */ +@Slf4j +public class FlakyTestJUnit4RetryRule implements TestRule { + + @Override + public Statement apply(Statement base, Description description) { + final Flaky annotation = description.getAnnotation(Flaky.class); + + if (annotation == null) { + // leave the statement as-is + return base; + } + + if (annotation.githubIssueUrl().trim().length() == 0) { + throw new IllegalArgumentException("A GitHub issue URL must be set for usages of the @Flaky annotation"); + } + + final int maxTries = annotation.maxTries(); + + if (maxTries < 1) { + throw new IllegalArgumentException("@Flaky annotation maxTries must be at least one"); + } + + final LocalDate reviewDate; + try { + reviewDate = LocalDate.parse(annotation.reviewDate()); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException( + "@Flaky reviewDate could not be parsed. Please provide a date in yyyy-mm-dd format" + ); + } + + // the annotation should only have an effect before the review date, to encourage review and resolution + if (LocalDate.now().isBefore(reviewDate)) { + return new RetryingStatement(base, description, maxTries); + } else { + return base; + } + } + + private static class RetryingStatement extends Statement { + + private final Statement base; + + private final Description description; + + private final int maxTries; + + RetryingStatement(Statement base, Description description, int maxTries) { + this.base = base; + this.description = description; + this.maxTries = maxTries; + } + + @Override + public void evaluate() { + int attempts = 0; + final List causes = new ArrayList<>(); + + while (++attempts <= maxTries) { + try { + base.evaluate(); + return; + } catch (Throwable throwable) { + log.warn("Retrying @Flaky-annotated test: {}", description.getDisplayName()); + causes.add(throwable); + } + } + + throw new IllegalStateException( + "@Flaky-annotated test failed despite retries.", + new MultipleFailureException(causes) + ); + } + } +} diff --git a/test-support/src/test/java/org/testcontainers/testsupport/FlakyRuleTest.java b/test-support/src/test/java/org/testcontainers/testsupport/FlakyRuleTest.java new file mode 100644 index 00000000000..60a311922d0 --- /dev/null +++ b/test-support/src/test/java/org/testcontainers/testsupport/FlakyRuleTest.java @@ -0,0 +1,210 @@ +package org.testcontainers.testsupport; + +import org.junit.Test; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; + +import java.lang.annotation.Annotation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +public class FlakyRuleTest { + + private static final String VALID_URL = "http://some.url/here"; + + private static final String INVALID_URL = ""; + + private static final String VALID_DATE_IN_FAR_FUTURE = "2063-04-05"; + + private static final String VALID_DATE_IN_PAST = "1991-08-16"; + + private static final String INVALID_DATE = "91-01-45"; + + private static final int DEFAULT_TRIES = 3; + + private FlakyTestJUnit4RetryRule rule = new FlakyTestJUnit4RetryRule(); + + @Test + public void testIgnoresMethodWithoutAnnotation() throws Throwable { + final Description description = newDescriptionWithoutAnnotation(); + final DummyStatement statement = newStatement(3); + try { + rule.apply(statement, description).evaluate(); + fail("Should not reach here"); + } catch (Exception ignored) {} + assertThat(statement.invocationCount) + .as("The statement should only be invoked once, even if it throws") + .isEqualTo(1); + } + + @Test + public void testRetriesMethodWithAnnotationUntilFailure() throws Throwable { + final Description description = newDescriptionWithAnnotation(); + + final DummyStatement statement = newStatement(3); + try { + rule.apply(statement, description).evaluate(); + fail("Should not reach here"); + } catch (Exception ignored) {} + assertThat(statement.invocationCount).as("The statement should be invoked three times").isEqualTo(3); + } + + @Test + public void testCustomRetryCount() throws Throwable { + final Description description = newDescriptionWithAnnotationAndCustomTries(10); + + final DummyStatement statement = newStatement(10); + try { + rule.apply(statement, description).evaluate(); + fail("Should not reach here"); + } catch (Exception ignored) {} + assertThat(statement.invocationCount).as("The statement should be invoked ten times").isEqualTo(10); + } + + @Test + public void testRetriesMethodWithAnnotationUntilSuccess() throws Throwable { + final Description description = newDescriptionWithAnnotation(); + + final DummyStatement statement = newStatement(2); + + rule.apply(statement, description).evaluate(); + + assertThat(statement.invocationCount) + .as("The statement should be invoked three times, and succeed the third time") + .isEqualTo(3); + } + + @Test + public void testDoesNotRetryMethodWithAnnotationIfNotThrowing() throws Throwable { + final Description description = newDescriptionWithAnnotation(); + + final DummyStatement statement = newStatement(0); + + rule.apply(statement, description).evaluate(); + + assertThat(statement.invocationCount).as("The statement should be invoked once").isEqualTo(1); + } + + @Test + public void testTreatsExpiredAnnotationAsNoAnnotation() throws Throwable { + final Description description = newDescriptionWithExpiredAnnotation(); + final DummyStatement statement = newStatement(3); + try { + rule.apply(statement, description).evaluate(); + fail("Should not reach here"); + } catch (Exception ignored) {} + assertThat(statement.invocationCount) + .as("The statement should only be invoked once, even if it throws") + .isEqualTo(1); + } + + @Test + public void testThrowsOnInvalidDateFormat() throws Throwable { + final Description description = newDescriptionWithAnnotation(INVALID_DATE, VALID_URL); + final DummyStatement statement = newStatement(3); + try { + rule.apply(statement, description).evaluate(); + fail("Should not reach here"); + } catch (IllegalArgumentException ignored) {} + assertThat(statement.invocationCount) + .as("The statement should not be invoked if the annotation is invalid") + .isEqualTo(0); + } + + @Test + public void testThrowsOnInvalidGitHubUrl() throws Throwable { + final Description description = newDescriptionWithAnnotation(VALID_DATE_IN_FAR_FUTURE, INVALID_URL); + final DummyStatement statement = newStatement(3); + try { + rule.apply(statement, description).evaluate(); + fail("Should not reach here"); + } catch (IllegalArgumentException ignored) {} + assertThat(statement.invocationCount) + .as("The statement should not be invoked if the annotation is invalid") + .isEqualTo(0); + } + + private Description newDescriptionWithAnnotation(String reviewDate, String gitHubUrl) { + return Description.createTestDescription( + "SomeTestClass", + "someMethod", + newAnnotation(reviewDate, gitHubUrl, DEFAULT_TRIES) + ); + } + + private Description newDescriptionWithoutAnnotation() { + return Description.createTestDescription("SomeTestClass", "someMethod"); + } + + private Description newDescriptionWithAnnotation() { + return Description.createTestDescription( + "SomeTestClass", + "someMethod", + newAnnotation(VALID_DATE_IN_FAR_FUTURE, VALID_URL, DEFAULT_TRIES) + ); + } + + private Description newDescriptionWithAnnotationAndCustomTries(int maxTries) { + return Description.createTestDescription( + "SomeTestClass", + "someMethod", + newAnnotation(VALID_DATE_IN_FAR_FUTURE, VALID_URL, maxTries) + ); + } + + private Description newDescriptionWithExpiredAnnotation() { + return Description.createTestDescription( + "SomeTestClass", + "someMethod", + newAnnotation(VALID_DATE_IN_PAST, VALID_URL, DEFAULT_TRIES) + ); + } + + private Flaky newAnnotation(final String reviewDate, String gitHubUrl, int tries) { + return new Flaky() { + @Override + public Class annotationType() { + return Flaky.class; + } + + @Override + public String githubIssueUrl() { + return gitHubUrl; + } + + @Override + public String reviewDate() { + return reviewDate; + } + + @Override + public int maxTries() { + return tries; + } + }; + } + + private DummyStatement newStatement(int timesToThrow) { + final DummyStatement statement = new DummyStatement(); + statement.shouldThrowTimes = timesToThrow; + return statement; + } + + private static class DummyStatement extends Statement { + + int shouldThrowTimes = -1; + + int invocationCount = 0; + + @Override + public void evaluate() { + invocationCount++; + + if (shouldThrowTimes > 0) { + shouldThrowTimes--; + throw new RuntimeException(); + } + } + } +} diff --git a/test-support/src/test/resources/logback-test.xml b/test-support/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..535e406fc13 --- /dev/null +++ b/test-support/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + +