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/.github/CODEOWNERS b/.github/CODEOWNERS index add6048a437..6f0c579e964 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,14 +1 @@ -# Each line is a file pattern followed by one or more owners. - -# These owners will be the default reviewers for everything in -# the repo. - -* @rnorth @bsideup @kiview - -# The last matching pattern takes the most -# precedence. - -# Contributed modules can have different reviewers - -modules/mssqlserver/ @StefanHufschmidt @rnorth @bsideup @kiview -modules/vault/ @mikeoswald @rnorth @bsideup @kiview +* @testcontainers/java-team 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/dependabot.yml b/.github/dependabot.yml index 16b59da6dfa..789ec351da2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,148 +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: "weekly" + 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: "weekly" + 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: "weekly" + 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: "weekly" + 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: "weekly" + interval: "monthly" open-pull-requests-limit: 10 - package-ecosystem: "gradle" directory: "/modules/elasticsearch" schedule: - interval: "weekly" + 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: "weekly" + 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: "weekly" + 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: "weekly" + 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: "weekly" + 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: "weekly" + 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: "weekly" + interval: "monthly" open-pull-requests-limit: 10 - package-ecosystem: "gradle" directory: "/modules/mariadb" schedule: - interval: "weekly" + 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: "weekly" + 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: "weekly" + interval: "monthly" open-pull-requests-limit: 10 - package-ecosystem: "gradle" directory: "/modules/mysql" schedule: - interval: "weekly" + interval: "monthly" open-pull-requests-limit: 10 - package-ecosystem: "gradle" directory: "/modules/neo4j" schedule: - interval: "weekly" + 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: "weekly" + 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: "weekly" + 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: "weekly" + 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: "weekly" + 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: "weekly" + 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: "weekly" + 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: "weekly" + 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: "weekly" + 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: "weekly" + 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" - directory: "/" + 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 index 4da341055c2..07376824bf7 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -23,3 +23,4 @@ categories: - 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/stale.yml b/.github/stale.yml deleted file mode 100644 index 5f903fb0189..00000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,25 +0,0 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 90 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 14 -# Issues with these labels will never be considered stale -exemptLabels: - - pinned - - security - - "resolution/acknowledged" - - "help wanted" - - "good first issue" -# Label to use when marking an issue as stale -staleLabel: stale -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. If you believe - this is a mistake, please reply to this comment to keep it open. If there isn't - one already, a PR to fix or at least reproduce the problem in a test case will - always help us get back on track to tackle this. -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: > - This issue has been automatically closed due to inactivity. We apologise if - this is still an active problem for you, and would ask you to re-open the - issue if this is the case. diff --git a/.github/workflows/ci-docker-wormhole.yml b/.github/workflows/ci-docker-wormhole.yml index d750d8b39e0..45957af1e28 100644 --- a/.github/workflows/ci-docker-wormhole.yml +++ b/.github/workflows/ci-docker-wormhole.yml @@ -1,14 +1,50 @@ name: CI-Docker-Wormhole on: - pull_request: {} - push: { branches: [ master ] } + 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-18.04 + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Build with Gradle run: | docker run -i --rm \ @@ -16,5 +52,6 @@ jobs: -v "$HOME:$HOME" \ -v "$PWD:$PWD" \ -w "$PWD" \ - openjdk:8-jdk-alpine \ + -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-examples.yml b/.github/workflows/ci-examples.yml deleted file mode 100644 index cea7180566d..00000000000 --- a/.github/workflows/ci-examples.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: CI-Examples - -on: - pull_request: {} - push: { branches: [ master ] } - -env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - -jobs: - find_gradle_jobs: - runs-on: ubuntu-18.04 - outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-java@v1 - with: - java-version: '1.8' - - name: Cache Gradle Home files - uses: actions/cache@v2.1.3 - continue-on-error: true - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-home-testmatrix-examples-${{ hashFiles('**/*.gradle') }} - - 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 "::set-output name=matrix::{\"gradle_args\":$TASKS}" - check: - needs: find_gradle_jobs - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.find_gradle_jobs.outputs.matrix) }} - runs-on: ubuntu-18.04 - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-java@v1 - with: - java-version: '1.8' - - name: Cache Gradle Home files - uses: actions/cache@v2.1.3 - continue-on-error: true - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-home-examples-${{matrix.gradle_args}}_check-${{ hashFiles('**/*.gradle') }} - - name: Clear existing docker image cache - run: docker image prune -af - - name: Build and test Examples with Gradle (${{matrix.gradle_args}}) - working-directory: ./examples/ - run: | - ./gradlew --no-daemon --continue --scan --info ${{matrix.gradle_args}} diff --git a/.github/workflows/ci-rootless.yml b/.github/workflows/ci-rootless.yml index 71ded85cf53..ee48b7dc7b6 100644 --- a/.github/workflows/ci-rootless.yml +++ b/.github/workflows/ci-rootless.yml @@ -1,34 +1,57 @@ name: CI-Docker-Rootless on: - pull_request: {} - push: { branches: [ master ] } + 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-18.04 - strategy: - fail-fast: false - matrix: - include: - - { XDG_RUNTIME_DIR: "" } - - { XDG_RUNTIME_DIR: "/tmp/docker-testcontainers/" } - env: - XDG_RUNTIME_DIR: ${{ matrix.XDG_RUNTIME_DIR }} + runs-on: ubuntu-22.04 + permissions: + checks: write steps: - - uses: actions/checkout@v2 - - name: debug - run: id -u; whoami - - name: uninstall rootful Docker - run: sudo apt-get -q -y --purge remove moby-engine moby-buildx && sudo rm -rf /var/run/docker.sock - - name: install rootless Docker - run: | - mkdir -p $XDG_RUNTIME_DIR || true - curl -fsSL https://get.docker.com/rootless | sh > init.sh - cat init.sh - source <(grep '^export' init.sh) - PATH=$HOME/bin:$PATH dockerd-rootless.sh --experimental --storage-driver vfs & - sleep 1 - DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock docker info || ls -la $XDG_RUNTIME_DIR + - 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 index f49b6426a2e..62dd2acc4ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,65 +1,186 @@ name: CI on: - pull_request: {} - push: { branches: [ master ] } + 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: - runs-on: ubuntu-18.04 + needs: [core] + runs-on: ubuntu-22.04 outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - - uses: actions/checkout@v2 - - uses: actions/setup-java@v1 - with: - java-version: '1.8' - - name: Cache Gradle Home files - uses: actions/cache@v2.1.3 - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-home-testmatrix-${{ hashFiles('**/*.gradle') }} + - 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: | - TASKS=$(./gradlew --no-daemon --parallel -q testMatrix) + set -o pipefail + TASKS=$(./gradlew --no-daemon --parallel -q testMatrix | jq 'del(.[] | select(. == ":testcontainers:check" or startswith(":docs:")))' --compact-output) echo $TASKS - echo "::set-output name=matrix::{\"gradle_args\":$TASKS}" + echo "matrix={\"gradle_args\":$TASKS}" >> $GITHUB_OUTPUT check: - needs: find_gradle_jobs + needs: [find_gradle_jobs] strategy: fail-fast: false matrix: ${{ fromJson(needs.find_gradle_jobs.outputs.matrix) }} - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 + permissions: + checks: write steps: - - uses: actions/checkout@v2 - - uses: actions/setup-java@v1 - with: - java-version: '1.8' - - name: Cache Gradle Home files - uses: actions/cache@v2.1.3 - continue-on-error: true - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-home-${{matrix.gradle_args}}_check-${{ hashFiles('**/*.gradle') }} - - name: Clear existing docker image cache - run: docker image prune -af + - 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}} - httpclient5_test: - runs-on: ubuntu-18.04 + - 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@v2 - - name: Build with Gradle + - 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: | - echo "transport.type=httpclient5" > $HOME/.testcontainers.properties - cat $HOME/.testcontainers.properties - ./gradlew --no-daemon --scan testcontainers:test --tests '*EventStreamTest' + 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 index 8f88ac2231e..863b8d979a9 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -3,12 +3,21 @@ name: Release Drafter on: push: branches: - - master + - 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@4d1215c #v5.13.0 + - 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/update-docs-version.yml b/.github/workflows/update-docs-version.yml index d2eb803fda2..beec8ed1e77 100644 --- a/.github/workflows/update-docs-version.yml +++ b/.github/workflows/update-docs-version.yml @@ -1,27 +1,33 @@ name: Update docs version on: - release: - types: [published] + release: + types: [ published ] + +permissions: + contents: read jobs: - build: - runs-on: ubuntu-18.04 - steps: - - uses: actions/checkout@v2 - with: - ref: master - - name: Update latest_version property in mkdocs.yml - run: | - sed -i "s/latest_version: .*/latest_version: ${GITHUB_REF##*/}/g" mkdocs.yml - git diff - - name: Commit changes - run: | - git config user.email "nobody@testcontainers.org" - git config user.name "GitHub Action - Update docs version" - git add mkdocs.yml - git commit -m "Update documentation version to ${GITHUB_REF##*/}" -m "skip-checks: true" || echo "not changed" - - name: Push changes - uses: ad-m/github-push-action@40bf560936a8022e68a3c00e7d2abefaf01305a6 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} + 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 index e8f4f130b12..9f710f30ec5 100644 --- a/.github/workflows/update-gradle-wrapper.yml +++ b/.github/workflows/update-gradle-wrapper.yml @@ -4,17 +4,24 @@ 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@v2 + - uses: actions/checkout@v6 - name: Update Gradle Wrapper - uses: gradle-update/update-gradle-wrapper-action@74a035ce333f39c84811ce1167669bfa6c65f440 + uses: gradle-update/update-gradle-wrapper-action@512b1875f3b6270828abfe77b247d5895a2da1e5 # v1.0.13 with: repo-token: ${{ secrets.GITHUB_TOKEN }} labels: dependencies - - uses: gradle/wrapper-validation-action@e2c57acffb2c9aa5a8dc6eda2bbae0b6e495bd4c + - 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 1b6070306a2..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: @@ -46,6 +47,7 @@ node_modules/ .gradle/ build/ +!buildSrc/src/main/groovy/org/testcontainers/build out/ *.class @@ -63,3 +65,9 @@ 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/.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 ec7a5d343d9..00000000000 --- a/.travis.yml +++ /dev/null @@ -1,24 +0,0 @@ -if: tag is present - -language: java -jdk: - - openjdk8 - -sudo: required -services: - - docker - -jobs: - include: - - - stage: deploy - sudo: false - services: [] - install: skip - script: skip - deploy: - provider: script - script: ./gradlew -Pversion=$TRAVIS_TAG release --scan --no-daemon -i - on: - tags: true - branch: master diff --git a/CHANGELOG.md b/CHANGELOG.md index aac8c306777..cef3d65e346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -281,7 +281,7 @@ ## [1.1.8] - 2017-01-22 ### 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 accomodate 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)) +- 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)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83afa8a9ea8..27a2ce70f7a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,6 @@ There are additional docs describing [contributing documentation changes](./docs Testcontainers is [in the GitHub Sponsors program](https://github.com/sponsors/testcontainers)! -This repository is supported by our sponsors, meaning that issues are eligible to have a 'bounty' attached to them by sponsors. +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). -Please see [the bounty policy page](https://testcontainers.org/bounty) if you are interested, either as a sponsor or as a contributor. \ No newline at end of file +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/Pipfile b/Pipfile deleted file mode 100644 index 2236de86daf..00000000000 --- a/Pipfile +++ /dev/null @@ -1,15 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] - -[packages] -mkdocs = "*" -mkdocs-codeinclude-plugin = "*" -mkdocs-material = "*" -mkdocs-markdownextradata-plugin = "*" - -[requires] -python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 899f8c82170..00000000000 --- a/Pipfile.lock +++ /dev/null @@ -1,158 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "c9a90b5f198797e8a6165fa7cb6c2143158bed425d0a8c5f63dc37ea4dcab632" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.7" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "click": { - "hashes": [ - "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", - "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" - ], - "version": "==7.0" - }, - "jinja2": { - "hashes": [ - "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", - "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" - ], - "version": "==2.11.2" - }, - "livereload": { - "hashes": [ - "sha256:29cadfabcedd12eed792e0131991235b9d4764d4474bed75cf525f57109ec0a2", - "sha256:e632a6cd1d349155c1d7f13a65be873b38f43ef02961804a1bba8d817fa649a7" - ], - "version": "==2.6.0" - }, - "markdown": { - "hashes": [ - "sha256:c00429bd503a47ec88d5e30a751e147dcb4c6889663cd3e2ba0afe858e009baa", - "sha256:d02e0f9b04c500cde6637c11ad7c72671f359b87b9fe924b2383649d8841db7c" - ], - "version": "==3.0.1" - }, - "markupsafe": { - "hashes": [ - "sha256:048ef924c1623740e70204aa7143ec592504045ae4429b59c30054cb31e3c432", - "sha256:130f844e7f5bdd8e9f3f42e7102ef1d49b2e6fdf0d7526df3f87281a532d8c8b", - "sha256:19f637c2ac5ae9da8bfd98cef74d64b7e1bb8a63038a3505cd182c3fac5eb4d9", - "sha256:1b8a7a87ad1b92bd887568ce54b23565f3fd7018c4180136e1cf412b405a47af", - "sha256:1c25694ca680b6919de53a4bb3bdd0602beafc63ff001fea2f2fc16ec3a11834", - "sha256:1f19ef5d3908110e1e891deefb5586aae1b49a7440db952454b4e281b41620cd", - "sha256:1fa6058938190ebe8290e5cae6c351e14e7bb44505c4a7624555ce57fbbeba0d", - "sha256:31cbb1359e8c25f9f48e156e59e2eaad51cd5242c05ed18a8de6dbe85184e4b7", - "sha256:3e835d8841ae7863f64e40e19477f7eb398674da6a47f09871673742531e6f4b", - "sha256:4e97332c9ce444b0c2c38dd22ddc61c743eb208d916e4265a2a3b575bdccb1d3", - "sha256:525396ee324ee2da82919f2ee9c9e73b012f23e7640131dd1b53a90206a0f09c", - "sha256:52b07fbc32032c21ad4ab060fec137b76eb804c4b9a1c7c7dc562549306afad2", - "sha256:52ccb45e77a1085ec5461cde794e1aa037df79f473cbc69b974e73940655c8d7", - "sha256:5c3fbebd7de20ce93103cb3183b47671f2885307df4a17a0ad56a1dd51273d36", - "sha256:5e5851969aea17660e55f6a3be00037a25b96a9b44d2083651812c99d53b14d1", - "sha256:5edfa27b2d3eefa2210fb2f5d539fbed81722b49f083b2c6566455eb7422fd7e", - "sha256:7d263e5770efddf465a9e31b78362d84d015cc894ca2c131901a4445eaa61ee1", - "sha256:83381342bfc22b3c8c06f2dd93a505413888694302de25add756254beee8449c", - "sha256:857eebb2c1dc60e4219ec8e98dfa19553dae33608237e107db9c6078b1167856", - "sha256:98e439297f78fca3a6169fd330fbe88d78b3bb72f967ad9961bcac0d7fdd1550", - "sha256:bf54103892a83c64db58125b3f2a43df6d2cb2d28889f14c78519394feb41492", - "sha256:d9ac82be533394d341b41d78aca7ed0e0f4ba5a2231602e2f05aa87f25c51672", - "sha256:e982fe07ede9fada6ff6705af70514a52beb1b2c3d25d4e873e82114cf3c5401", - "sha256:edce2ea7f3dfc981c4ddc97add8a61381d9642dc3273737e756517cc03e84dd6", - "sha256:efdc45ef1afc238db84cb4963aa689c0408912a0239b0721cb172b4016eb31d6", - "sha256:f137c02498f8b935892d5c0172560d7ab54bc45039de8805075e19079c639a9c", - "sha256:f82e347a72f955b7017a39708a3667f106e6ad4d10b25f237396a7115d8ed5fd", - "sha256:fb7c206e01ad85ce57feeaaa0bf784b97fa3cad0d4a5737bc5295785f5c613a1" - ], - "version": "==1.1.0" - }, - "mkdocs": { - "hashes": [ - "sha256:17d34329aad75d5de604b9ed4e31df3a4d235afefdc46ce7b1964fddb2e1e939", - "sha256:8cc8b38325456b9e942c981a209eaeb1e9f3f77b493ad755bfef889b9c8d356a" - ], - "version": "==1.0.4" - }, - "mkdocs-codeinclude-plugin": { - "editable": true, - "git": "https://github.com/rnorth/mkdocs-codeinclude-plugin", - "ref": "99417fdad0634ccfeea7eaa8ba648a08c7513e7c" - }, - "mkdocs-markdownextradata-plugin": { - "hashes": [ - "sha256:64d1c966b288d653f51f7531c03204eb988d0d77e56055c9d703d99105259a36" - ], - "index": "pypi", - "version": "==0.0.5" - }, - "mkdocs-material": { - "hashes": [ - "sha256:524debb6ee8ee89cee08886f2a67c3c3875c0ee9579c598d7448cbd2607cd3b7", - "sha256:62ae84082fa9f077c86b7db63e7bedf392005041b451defc850f8d0887a11e91" - ], - "index": "pypi", - "version": "==3.2.0" - }, - "pygments": { - "hashes": [ - "sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a", - "sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d" - ], - "version": "==2.3.1" - }, - "pymdown-extensions": { - "hashes": [ - "sha256:25b0a7967fa697b5035e23340a48594e3e93acb10b06d74574218ace3347d1df", - "sha256:6cf0cf36b5a03b291ace22dc2f320f4789ce56fbdb6635a3be5fadbf5d7694dd" - ], - "version": "==6.0" - }, - "pyyaml": { - "hashes": [ - "sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b", - "sha256:3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf", - "sha256:40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a", - "sha256:558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3", - "sha256:a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1", - "sha256:aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1", - "sha256:bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613", - "sha256:d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04", - "sha256:d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f", - "sha256:e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537", - "sha256:e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531" - ], - "version": "==3.13" - }, - "six": { - "hashes": [ - "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", - "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" - ], - "version": "==1.12.0" - }, - "tornado": { - "hashes": [ - "sha256:0662d28b1ca9f67108c7e3b77afabfb9c7e87bde174fbda78186ecedc2499a9d", - "sha256:4e5158d97583502a7e2739951553cbd88a72076f152b4b11b64b9a10c4c49409", - "sha256:732e836008c708de2e89a31cb2fa6c0e5a70cb60492bee6f1ea1047500feaf7f", - "sha256:8154ec22c450df4e06b35f131adc4f2f3a12ec85981a203301d310abf580500f", - "sha256:8e9d728c4579682e837c92fdd98036bd5cdefa1da2aaf6acf26947e6dd0c01c5", - "sha256:d4b3e5329f572f055b587efc57d29bd051589fb5a43ec8898c77a47ec2fa2bbb", - "sha256:e5f2585afccbff22390cddac29849df463b252b711aa2ce7c5f3f342a5b3b444" - ], - "version": "==5.1.1" - } - }, - "develop": {} -} diff --git a/README.md b/README.md index ec5ab97ca12..2482154c7be 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,20 @@ # 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)[ ![Download](https://api.bintray.com/packages/testcontainers/releases/testcontainers/images/download.svg) ](https://bintray.com/testcontainers/releases/testcontainers/_latestVersion) +[![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,10 +22,10 @@ See [LICENSE](LICENSE). ## Copyright -Copyright (c) 2015 - 2020 Richard North and other authors. +Copyright (c) 2015 - 2021 Richard North and other authors. -MS SQL Server module is (c) 2017 - 2020 G DATA Software AG and other authors. +MS SQL Server module is (c) 2017 - 2021 G DATA Software AG and other authors. -Hashicorp Vault module is (c) 2017 - 2020 Capital One Services, LLC and other authors. +Hashicorp Vault module is (c) 2017 - 2021 Capital One Services, LLC and other authors. See [contributors](https://github.com/testcontainers/testcontainers-java/graphs/contributors) for all contributors. diff --git a/RELEASING.md b/RELEASING.md index aaba172d20e..d5c79b04ed2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,19 +1,28 @@ # Release process -Testcontainers' release process is automated as a Travis deployment. This describes the basic steps for a project member to perform a release. +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 master branch is building and that tests are passing. -1. Ensure that the [`CHANGELOG`](CHANGELOG.md) file is up to date and includes all merged features. +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. Check that the Travis build passed. -1. Release of published artifacts to Bintray is fully automated. -1. After successful publication to Bintray, the artifacts must be synced to Maven Central. -1. When available through Maven Central, poke [Richard North](https://github.com/rnorth) to announce the release on Twitter! +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 Gradle and Bintray. -* Bintray will automatically promote the release to Maven Central. -* Travis secrets hold Bintray username/passwords that are used for publishing. +* 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/azure-pipelines.yml b/azure-pipelines.yml index 1471f9948ca..9279b3233f8 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -1,34 +1,38 @@ jobs: -- job: azure_pipeline_tests +- job: core_tests + timeoutInMinutes: 60 steps: - # Run a minimal set of tests in normal Linux pipeline builds + # Run all core tests when running the Windows CI tests - task: Gradle@2 - condition: eq(variables['Agent.OS'], 'Linux') - displayName: Build & test (Linux - minimal core) + 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.11' - options: "--no-daemon --continue" - tasks: "clean testcontainers:test --tests GenericContainerRuleTest" + jdkVersionOption: '1.8' + options: '--no-daemon --continue' + tasks: 'clean testcontainers:check' publishJUnitResults: true testResultsFiles: '**/TEST-*.xml' - # Run all tests when running the Windows CI tests +- 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 modules) + 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.11' + jdkVersionOption: '1.8' options: '--no-daemon --continue' - tasks: 'clean check' + tasks: 'clean check -x testcontainers:test' publishJUnitResults: true testResultsFiles: '**/TEST-*.xml' diff --git a/build.gradle b/build.gradle index c51b24909cb..f405ea2b92c 100644 --- a/build.gradle +++ b/build.gradle @@ -1,67 +1,96 @@ +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 '3.1.0' - id 'nebula.provided-base' version '3.0.3' - id 'com.github.johnrengelman.shadow' version '5.2.0' - id "com.jfrog.bintray" version "1.8.4" apply false + 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: 'nebula.provided-base' apply plugin: 'java' + apply plugin: 'java-library' apply plugin: 'idea' apply plugin: 'io.franzbecker.gradle-lombok' - apply plugin: 'com.github.johnrengelman.shadow' + apply from: "$rootDir/gradle/shading.gradle" + apply from: "$rootDir/gradle/spotless.gradle" + apply plugin: 'checkstyle' group = "org.testcontainers" - sourceCompatibility = 1.8 - targetCompatibility = 1.8 - compileJava.options.encoding = 'UTF-8' + 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 { - jcenter() mavenCentral() } + configurations { + provided + api.extendsFrom(provided) + } + lombok { - version = '1.18.12' + version = '1.18.30' } + task delombok(type: io.franzbecker.gradle.lombok.task.DelombokTask) { - def outputDir = file("$buildDir/delombok") - outputs.dir(outputDir) - for (srcDir in project.sourceSets.main.java.srcDirs) { - inputs.dir(srcDir) - args(srcDir, "-d", outputDir) + 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", "jdbc-test"].contains(it.name) && !it.path.startsWith(":docs:") && it != project(":docs") ) { + if ( ! ["test-support", "testcontainers-jdbc-test"].contains(it.name) && !it.path.startsWith(":docs:") && it != project(":docs") ) { apply from: "$rootDir/gradle/publishing.gradle" - apply from: "$rootDir/gradle/bintray.gradle" - - jar.archiveClassifier.set("original") - publishing { - publications { - mavenJava(MavenPublication) { publication -> - artifacts.removeAll { it.classifier == jar.archiveClassifier.get() } - artifact project.tasks.shadowJar - } - } + if (it.name != "bom") { + apply plugin: "me.champeau.gradle.japicmp" + tasks.register('japicmp', me.champeau.gradle.japicmp.JapicmpTask) + apply from: "$rootDir/gradle/japicmp.gradle" } - - task release(dependsOn: bintrayUpload) } test { + useJUnitPlatform() + defaultCharacterEncoding = "UTF-8" testLogging { displayGranularity 1 @@ -69,6 +98,14 @@ subprojects { 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 { @@ -96,38 +133,16 @@ subprojects { source = delombok.outputs } - shadowJar { - configurations = [] - classifier = null - - doFirst { - // See https://github.com/johnrengelman/shadow/blob/5.0.0/src/main/groovy/com/github/jengelman/gradle/plugins/shadow/tasks/ConfigureShadowRelocation.groovy - Set packages = [] - // Always read from core's configurations - for (configuration in tasks.getByPath(":testcontainers:shadowJar").configurations) { - for (jar in configuration.files) { - def jf = new java.util.jar.JarFile(jar) - 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)) - } - } - } - jf.close() - } - } - for (pkg in packages) { - pkg = pkg.replaceAll('/', '.') + 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' - tasks.shadowJar.relocate(pkg, "org.testcontainers.shaded.${pkg}") - } - } + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.0' } - dependencies { - testCompile 'ch.qos.logback:logback-classic:1.2.3' + 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/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 index 7613ed58ccc..d7a54a26afe 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,19 +1,4 @@ -buildscript { - repositories { - jcenter() - } - - dependencies { - // https://github.com/melix/japicmp-gradle-plugin/issues/36 - classpath 'com.google.guava:guava:30.1-jre' - } -} - -plugins { - id 'me.champeau.gradle.japicmp' version '0.2.9' -} - -apply plugin: 'com.github.johnrengelman.shadow' +apply plugin: 'com.gradleup.shadow' description = "Testcontainers Core" @@ -21,209 +6,140 @@ sourceSets { jarFileTest } +test.maxParallelForks = 4 + idea.module.testSourceDirs += sourceSets.jarFileTest.allSource.srcDirs -configurations { - baseline - shaded - [runtime, compileOnly, testCompile]*.extendsFrom shaded +jar { + manifest { + attributes('Implementation-Version': project.getProperty("version")) + } } shadowJar { - configurations = [project.configurations.shaded] - - mergeServiceFiles() - - exclude 'org/newsclub/**' - [ 'META-INF/NOTICE', 'META-INF/NOTICE.txt', 'META-INF/LICENSE', 'META-INF/LICENSE.txt', - 'META-INF/DEPENDENCIES', 'META-INF/maven/', 'META-INF/proguard/', 'META-INF/versions/*/module-info.class', - 'META-INF/services/README.md', - '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/java.security.Provider', - 'mozilla/public-suffix-list.txt', - 'module-info.class', ].each { exclude(it) } - - project.afterEvaluate { - dependencies { - for (id in project.configurations.compile.resolvedConfiguration.resolvedArtifacts*.moduleVersion*.id) { - exclude(dependency("${id.group}:${id.name}")) - } - } - } } task jarFileTest(type: Test) { + useJUnitPlatform() testClassesDirs = sourceSets.jarFileTest.output.classesDirs classpath = sourceSets.jarFileTest.runtimeClasspath - systemProperty("jarFile", shadowJar.outputs.files.singleFile) + 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) -task japicmp(type: me.champeau.gradle.japicmp.JapicmpTask) { - dependsOn(tasks.shadowJar) - - oldClasspath = configurations.baseline - newClasspath = shadowJar.outputs.files - ignoreMissingClasses = true - - accessModifier = "protected" - failOnModification = true - failOnSourceIncompatibility = true - +tasks.japicmp { packageExcludes = [ "com.github.dockerjava.*", "org.testcontainers.shaded.*", ] - // TODO remove once 1.15.0 is released - classExcludes = [ - "org.testcontainers.containers.AmbassadorContainer", - "org.testcontainers.containers.traits.VncService", - "org.testcontainers.containers.wait.HostPortWaitStrategy", - "org.testcontainers.containers.wait.HttpWaitStrategy", - "org.testcontainers.containers.wait.LogMessageWaitStrategy", - "org.testcontainers.containers.wait.Wait", - "org.testcontainers.containers.wait.WaitAllStrategy", - "org.testcontainers.containers.wait.WaitStrategy", - "org.testcontainers.dockerclient.AuditLoggingDockerClient", - "org.testcontainers.dockerclient.LogToStringContainerCallback", - "org.testcontainers.dockerclient.DockerMachineClientProviderStrategy", - "org.testcontainers.dockerclient.EnvironmentAndSystemPropertyClientProviderStrategy", - "org.testcontainers.dockerclient.NpipeSocketClientProviderStrategy", - "org.testcontainers.dockerclient.ProxiedUnixSocketClientProviderStrategy", - "org.testcontainers.dockerclient.WindowsClientProviderStrategy", - "org.testcontainers.dockerclient.UnixSocketClientProviderStrategy", - "org.testcontainers.dockerclient.auth.AuthDelegatingDockerClientConfig", - "org.testcontainers.containers.GenericContainer\$AbstractWaitStrategy", - "org.testcontainers.dockerclient.transport.okhttp.NamedPipeSocketFactory", - "org.testcontainers.dockerclient.transport.okhttp.OkHttpDockerCmdExecFactory", - "org.testcontainers.dockerclient.transport.okhttp.UnixSocketFactory", - "org.testcontainers.containers.output.FrameConsumerResultCallback", - "org.testcontainers.images.TimeLimitedLoggedPullImageResultCallback", - ] - - methodExcludes = [ - "org.testcontainers.dockerclient.DockerClientConfigUtils#getDetectedDockerHostIp()", - "org.testcontainers.dockerclient.DockerClientConfigUtils#getDockerHostIpAddress(com.github.dockerjava.core.DockerClientConfig)", - - "org.testcontainers.dockerclient.DockerClientProviderStrategy#checkOSType()", - "org.testcontainers.dockerclient.DockerClientProviderStrategy#getClientForConfig(com.github.dockerjava.core.DockerClientConfig)", - "org.testcontainers.dockerclient.DockerClientProviderStrategy#ping(com.github.dockerjava.api.DockerClient, int)", - "org.testcontainers.dockerclient.DockerClientProviderStrategy#test()", - - "org.testcontainers.dockerclient.UnixSocketClientProviderStrategy#tryConfiguration(java.lang.String)", - - "org.testcontainers.utility.ResourceReaper#start(java.lang.String, com.github.dockerjava.api.DockerClient, boolean)", - - "org.testcontainers.containers.Container#fetchDockerDaemonInfo()", - "org.testcontainers.containers.Container#getDockerDaemonInfo()", - - "org.testcontainers.containers.GenericContainer#fetchDockerDaemonInfo()", - "org.testcontainers.containers.GenericContainer#getDockerDaemonInfo()", - - "org.testcontainers.containers.Container\$ExecResult#Container\$ExecResult(java.lang.String, java.lang.String)", - ] - - fieldExcludes = [ - "org.testcontainers.containers.GenericContainer#containerId", - "org.testcontainers.containers.GenericContainer#containerName", - "org.testcontainers.containers.GenericContainer#dockerDaemonInfo", + classExcludes = [] - "org.testcontainers.dockerclient.DockerClientProviderStrategy#LOGGER", - "org.testcontainers.dockerclient.DockerClientProviderStrategy#client", - "org.testcontainers.dockerclient.DockerClientProviderStrategy#config", - ] + methodExcludes = [] - onlyBinaryIncompatibleModified = true - htmlOutputFile = file("$buildDir/reports/japi.html") -} -// 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) + fieldExcludes = [] } configurations.all { resolutionStrategy { - // use lower Jackson version - force 'com.fasterxml.jackson.core:jackson-databind:2.8.8' + force 'com.fasterxml.jackson.core:jackson-databind:2.18.4' + force 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.4' } } dependencies { - baseline 'org.testcontainers:testcontainers:1.15.1', { - exclude group: "*", module: "*" + 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') } - compile 'junit:junit:4.12' - compile 'org.slf4j:slf4j-api:1.7.30' - compileOnly 'org.jetbrains:annotations:20.1.0' - testCompileClasspath 'org.jetbrains:annotations:20.0.0' - compile 'org.apache.commons:commons-compress:1.20' - compile ('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' } - compile 'org.rnorth.visible-assertions:visible-assertions:2.1.2' + shaded 'org.awaitility:awaitility:4.3.0' - compile "com.github.docker-java:docker-java-api:3.2.7" + api platform('com.github.docker-java:docker-java-bom:3.7.1') + shaded platform('com.github.docker-java:docker-java-bom:3.7.1') - shaded ('com.github.docker-java:docker-java-core:3.2.7') { - exclude(group: 'com.github.docker-java', module: 'docker-java-api') - exclude(group: 'com.github.docker-java', module: 'docker-java-transport') - exclude(group: 'com.fasterxml.jackson.core', module: 'jackson-annotations') - exclude(group: 'com.google.code.findbug') - exclude(group: 'org.slf4j') - exclude(group: 'org.apache.commons', module: 'commons-compress') - } + api "com.github.docker-java:docker-java-api" - shaded ('com.github.docker-java:docker-java-transport-okhttp:3.2.7') { - exclude(group: 'com.github.docker-java', module: 'docker-java-core') - exclude(group: 'net.java.dev.jna') - exclude(group: 'org.slf4j') + shaded('com.github.docker-java:docker-java-core') { + exclude group: 'com.google.guava', module: 'guava' } - compile 'com.github.docker-java:docker-java-transport-zerodep:3.2.7' + api 'com.github.docker-java:docker-java-transport-zerodep' - shaded "org.yaml:snakeyaml:1.27" + 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.12', { - exclude(group: 'org.slf4j') - } + shaded 'org.zeroturnaround:zt-exec:1.13.0' + + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.14.3' - testCompile 'org.apache.httpcomponents:httpclient:4.5.9' - testCompile 'redis.clients:jedis:3.4.0' - testCompile 'com.rabbitmq:amqp-client:5.9.0' - testCompile 'org.mongodb:mongo-java-driver:3.12.7' + 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' - testCompile ('org.mockito:mockito-core:3.6.28') { + testImplementation ('org.mockito:mockito-core:4.11.0') { exclude(module: 'hamcrest-core') } // Synthetic JAR used for MountableFileTest and DirectoryTarResourceTest - testCompile files('testlib/repo/fakejar/fakejar/0/fakejar-0.jar') + testImplementation files('testlib/repo/fakejar/fakejar/0/fakejar-0.jar') - testCompile 'org.assertj:assertj-core:3.18.1' - testCompile project(':test-support') + 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}" - jarFileTestCompile 'junit:junit:4.12' - jarFileTestCompile 'org.assertj:assertj-core:3.18.1' - jarFileTestCompile 'org.ow2.asm:asm-debug-all:5.2' + 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/src/jarFileTest/java/org/testcontainers/AbstractJarFileTest.java b/core/src/jarFileTest/java/org/testcontainers/AbstractJarFileTest.java index d6043422640..3c0e056d903 100644 --- a/core/src/jarFileTest/java/org/testcontainers/AbstractJarFileTest.java +++ b/core/src/jarFileTest/java/org/testcontainers/AbstractJarFileTest.java @@ -1,12 +1,13 @@ 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 static java.util.Collections.emptyMap; +import java.util.Collections; public abstract class AbstractJarFileTest { @@ -15,8 +16,9 @@ public abstract class AbstractJarFileTest { static { try { Path jarFilePath = Paths.get(System.getProperty("jarFile")); - URI jarFileUri = new URI("jar", jarFilePath.toUri().toString(), null); - FileSystem fileSystem = FileSystems.newFileSystem(jarFileUri, emptyMap()); + 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 index 4cf7b3a9e9f..8b6f380f6eb 100644 --- a/core/src/jarFileTest/java/org/testcontainers/JarFileShadingTest.java +++ b/core/src/jarFileTest/java/org/testcontainers/JarFileShadingTest.java @@ -1,7 +1,7 @@ package org.testcontainers; import org.assertj.core.api.ListAssert; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.io.IOException; import java.nio.file.Files; @@ -9,38 +9,39 @@ import static org.assertj.core.api.Assertions.assertThat; -public class JarFileShadingTest extends AbstractJarFileTest { +class JarFileShadingTest extends AbstractJarFileTest { @Test - public void testPackages() throws Exception { - assertThatFileList(root).containsOnly( - "org", - "META-INF" - ); - - assertThatFileList(root.resolve("org")).containsOnly( - "testcontainers" - ); + void testPackages() throws Exception { + assertThatFileList(root).containsOnly("org", "META-INF"); + + assertThatFileList(root.resolve("org")).containsOnly("testcontainers"); } @Test - public void testMetaInf() throws Exception { - assertThatFileList(root.resolve("META-INF")).containsOnly( + void testMetaInf() throws Exception { + assertThatFileList(root.resolve("META-INF")) + .containsOnly( "MANIFEST.MF", - "services" - ); + "services", + "versions", + "native-image", + "thirdparty-LICENSE", + "FastDoubleParser-NOTICE", + "FastDoubleParser-LICENSE" + ); } @Test - public void testMetaInfServices() throws Exception { + void testMetaInfServices() throws Exception { assertThatFileList(root.resolve("META-INF").resolve("services")) - .allMatch(it -> it.startsWith("org.testcontainers.")); + .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); + .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 index 6a5a766d24e..6f8a1c46941 100644 --- a/core/src/jarFileTest/java/org/testcontainers/PublicBinaryAPITest.java +++ b/core/src/jarFileTest/java/org/testcontainers/PublicBinaryAPITest.java @@ -2,12 +2,12 @@ import lombok.RequiredArgsConstructor; import org.assertj.core.api.Assertions; -import org.junit.Assume; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.junit.runners.Parameterized.Parameters; +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; @@ -30,14 +30,15 @@ /** * This test checks that we don't expose any shaded class in our public API. - * We use {@link Parameterized} runner here to create a test per public class in Testcontainers' JAR file. */ -@RunWith(Parameterized.class) +@ParameterizedClass +@MethodSource("data") @RequiredArgsConstructor public class PublicBinaryAPITest extends AbstractJarFileTest { - private static String SHADED_PACKAGE = "org.testcontainers.shaded."; - private static String SHADED_PACKAGE_PATH = SHADED_PACKAGE.replaceAll("\\.", "/"); + 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); @@ -45,93 +46,94 @@ public class PublicBinaryAPITest extends AbstractJarFileTest { Assertions.registerFormatterForType(MethodNode.class, it -> it.name + it.desc); } - @Parameters(name = "{0}") 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(); + 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.endsWith(".class")) { + return super.visitFile(path, attrs); + } - if (!fileName.startsWith("/org/testcontainers/") ) { - 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); - } + 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 }); + 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 super.visitFile(path, attrs); + } } - }); + ); return result; } - private final String fileName; + @Parameter(0) + private String fileName; - private final ClassNode classNode; + @Parameter(1) + private ClassNode classNode; - @Before + @BeforeEach public void setUp() { switch (classNode.name) { // Necessary evil case "org/testcontainers/dockerclient/UnixSocketClientProviderStrategy": case "org/testcontainers/dockerclient/DockerClientProviderStrategy": case "org/testcontainers/dockerclient/WindowsClientProviderStrategy": - Assume.assumeTrue(false); + case "org/testcontainers/utility/DynamicPollInterval": + Assumptions.assumeTrue(false); } } @Test - public void testSuperClass() { - assertThat(classNode.superName) - .doesNotStartWith(SHADED_PACKAGE_PATH); + void testSuperClass() { + assertThat(classNode.superName).doesNotStartWith(SHADED_PACKAGE_PATH); } @Test - public void testInterfaces() { - assertThat(classNode.interfaces) - .allSatisfy(it -> assertThat(it).doesNotStartWith(SHADED_PACKAGE_PATH)); + void testInterfaces() { + assertThat(classNode.interfaces).allSatisfy(it -> assertThat(it).doesNotStartWith(SHADED_PACKAGE_PATH)); } @Test - public void testMethodReturnTypes() { + 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)); + .filteredOn(it -> (it.access & (Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED)) != 0) + .allSatisfy(it -> assertThat(Type.getReturnType(it.desc).getClassName()).doesNotStartWith(SHADED_PACKAGE)); } @Test - public void testMethodArguments() { + 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)) - ); + .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 - public void testFields() { + 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) - ); + .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/DockerClientFactory.java b/core/src/main/java/org/testcontainers/DockerClientFactory.java index 98e8249e4e5..989aaf36be3 100644 --- a/core/src/main/java/org/testcontainers/DockerClientFactory.java +++ b/core/src/main/java/org/testcontainers/DockerClientFactory.java @@ -1,50 +1,48 @@ package org.testcontainers; import com.github.dockerjava.api.DockerClient; -import com.github.dockerjava.api.async.ResultCallback; +import com.github.dockerjava.api.DockerClientDelegate; import com.github.dockerjava.api.command.CreateContainerCmd; -import com.github.dockerjava.api.command.ListImagesCmd; +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.AccessMode; import com.github.dockerjava.api.model.Bind; -import com.github.dockerjava.api.model.Frame; -import com.github.dockerjava.api.model.Image; import com.github.dockerjava.api.model.Info; import com.github.dockerjava.api.model.Version; import com.github.dockerjava.api.model.Volume; import com.google.common.annotations.VisibleForTesting; -import com.google.common.collect.ImmutableMap; import lombok.Getter; import lombok.SneakyThrows; import lombok.Synchronized; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang.StringUtils; -import org.apache.commons.lang.SystemUtils; +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.ImageNameSubstitutor; import org.testcontainers.utility.MountableFile; import org.testcontainers.utility.ResourceReaper; import org.testcontainers.utility.TestcontainersConfiguration; -import java.io.ByteArrayOutputStream; -import java.io.IOException; 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.Map; -import java.util.Optional; import java.util.ServiceLoader; import java.util.UUID; import java.util.function.BiFunction; import java.util.function.Consumer; +import java.util.stream.Collectors; /** * Singleton class that provides initialized Docker clients. @@ -55,17 +53,34 @@ public class DockerClientFactory { 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 Map DEFAULT_LABELS = ImmutableMap.of( - TESTCONTAINERS_LABEL, "true", - TESTCONTAINERS_SESSION_ID_LABEL, SESSION_ID - ); + 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 final DockerImageName TINY_IMAGE = DockerImageName.parse("alpine:3.5"); private static DockerClientFactory instance; // Cached client configuration @@ -73,29 +88,31 @@ public class DockerClientFactory { DockerClientProviderStrategy strategy; @VisibleForTesting - DockerClient dockerClient; + DockerClient client; @VisibleForTesting RuntimeException cachedClientFailure; private String activeApiVersion; - private String activeExecutionDriver; @Getter(lazy = true) private final boolean fileMountingSupported = checkMountableFile(); - - static { - System.setProperty("org.testcontainers.shaded.io.netty.packagePrefix", "org.testcontainers.shaded."); - } - @VisibleForTesting - DockerClientFactory() { - - } + DockerClientFactory() {} public static DockerClient lazyClient() { - return LazyDockerClient.INSTANCE; + return new DockerClientDelegate() { + @Override + protected DockerClient getDockerClient() { + return instance().client(); + } + + @Override + public String toString() { + return "LazyDockerClient"; + } + }; } /** @@ -103,7 +120,7 @@ public static DockerClient lazyClient() { * * @return the singleton instance of DockerClientFactory */ - public synchronized static DockerClientFactory instance() { + public static synchronized DockerClientFactory instance() { if (instance == null) { instance = new DockerClientFactory(); } @@ -113,6 +130,7 @@ 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() { @@ -129,7 +147,7 @@ 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); @@ -144,73 +162,90 @@ public TransportConfig getTransportConfig() { @UnstableAPI public String getRemoteDockerUnixSocketPath() { - String dockerSocketOverride = System.getenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE"); - if (!StringUtils.isBlank(dockerSocketOverride)) { - return dockerSocketOverride; + 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; + 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() { - - if (dockerClient != null) { - return dockerClient; - } - // fail-fast if checks have failed previously if (cachedClientFailure != null) { log.debug("There is a cached checks failure - throwing", cachedClientFailure); throw cachedClientFailure; } + if (client != null) { + return client; + } + 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()); - final DockerClient client = new DelegatingDockerClient(strategy.getDockerClient()) { - @Override - public void close() { - throw new IllegalStateException("You should never close the global DockerClient!"); - } - }; - Info dockerInfo = client.infoCmd().exec(); + Info dockerInfo = strategy.getInfo(); + log.debug("Docker info: {}", dockerInfo.getRawValues()); Version version = client.versionCmd().exec(); + log.debug("Docker version: {}", version.getRawValues()); 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"); - - final String ryukContainerId; - - boolean useRyuk = !Boolean.parseBoolean(System.getenv("TESTCONTAINERS_RYUK_DISABLED")); - if (useRyuk) { - log.debug("Ryuk is enabled"); - try { - //noinspection deprecation - ryukContainerId = ResourceReaper.start(client); - } catch (RuntimeException e) { - cachedClientFailure = e; - throw e; - } - log.info("Ryuk started - will monitor and terminate Testcontainers containers on JVM exit"); - } else { - log.debug("Ryuk is disabled"); - ryukContainerId = null; + + 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(); @@ -220,22 +255,6 @@ public void close() { try { log.info("Checking the system..."); checkDockerVersion(version.getVersion()); - if (ryukContainerId != null) { - checkDiskSpace(client, ryukContainerId); - } else { - runInsideDocker( - client, - createContainerCmd -> { - createContainerCmd.withName("testcontainers-checks-" + SESSION_ID); - createContainerCmd.getHostConfig().withAutoRemove(true); - createContainerCmd.withCmd("tail", "-f", "/dev/null"); - }, - (__, containerId) -> { - checkDiskSpace(client, containerId); - return ""; - } - ); - } } catch (RuntimeException e) { cachedClientFailure = e; throw e; @@ -244,52 +263,15 @@ public void close() { log.debug("Checks are disabled"); } - dockerClient = client; - return dockerClient; + return client; } private void checkDockerVersion(String dockerVersion) { - boolean versionIsSufficient = new ComparableVersion(dockerVersion).compareTo(new ComparableVersion("1.6.0")) >= 0; + 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 checkDiskSpace(DockerClient dockerClient, String id) { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - - try { - dockerClient - .execStartCmd(dockerClient.execCreateCmd(id).withAttachStdout(true).withCmd("df", "-P").exec().getId()) - .exec(new ResultCallback.Adapter() { - @Override - public void onNext(Frame frame) { - if (frame == null) { - return; - } - switch (frame.getStreamType()) { - case RAW: - case STDOUT: - try { - outputStream.write(frame.getPayload()); - outputStream.flush(); - } catch (IOException e) { - onError(e); - } - } - } - }) - .awaitCompletion(); - } catch (Exception e) { - log.debug("Can't exec disk checking command", e); - } - - DiskSpaceUsage df = parseAvailableDiskSpace(outputStream.toString()); - - check( - "Docker environment should have more than 2GB free disk space", - df.availableMB.map(it -> it >= 2048).orElse(true) - ); - } - private void check(String message, boolean isSuccessful) { if (isSuccessful) { log.info("\u2714\ufe0e {}", message); @@ -302,14 +284,22 @@ private void check(String message, boolean isSuccessful) { private boolean checkMountableFile() { DockerClient dockerClient = client(); - MountableFile mountableFile = MountableFile.forClasspathResource(ResourceReaper.class.getName().replace(".", "/") + ".class"); + MountableFile mountableFile = MountableFile.forClasspathResource( + ResourceReaper.class.getName().replace(".", "/") + ".class" + ); Volume volume = new Volume("/dummy"); try { return runInsideDocker( - createContainerCmd -> createContainerCmd.withBinds(new Bind(mountableFile.getResolvedPath(), volume, AccessMode.ro)), + createContainerCmd -> { + createContainerCmd.withBinds(new Bind(mountableFile.getResolvedPath(), volume, AccessMode.ro)); + }, (__, containerId) -> { - try (InputStream stream = dockerClient.copyArchiveFromContainerCmd(containerId, volume.getPath()).exec()) { + try ( + InputStream stream = dockerClient + .copyArchiveFromContainerCmd(containerId, volume.getPath()) + .exec() + ) { stream.read(); return true; } catch (Exception e) { @@ -324,14 +314,26 @@ private boolean checkMountableFile() { } /** - * Check whether the image is available locally and pull it otherwise - */ + * Check whether the image is available locally and pull it otherwise + * + * @deprecated use {@link RemoteDockerImage} + */ @SneakyThrows + @Deprecated public void checkAndPullImage(DockerClient client, String image) { try { client.inspectImageCmd(image).exec(); - } catch (NotFoundException e) { - client.pullImageCmd(image).exec(new TimeLimitedLoggedPullImageResultCallback(log)).awaitCompletion(); + } 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(); + } } } @@ -342,18 +344,22 @@ public String dockerHostIpAddress() { return getOrInitializeStrategy().getDockerHostIpAddress(); } - public T runInsideDocker(Consumer createContainerCmdConsumer, BiFunction block) { - // We can't use client() here because it might create an infinite loop - return runInsideDocker(getOrInitializeStrategy().getDockerClient(), createContainerCmdConsumer, block); + public T runInsideDocker( + Consumer createContainerCmdConsumer, + BiFunction block + ) { + return runInsideDocker(TINY_IMAGE, createContainerCmdConsumer, block); } - private T runInsideDocker(DockerClient client, Consumer createContainerCmdConsumer, BiFunction block) { - - final String tinyImage = ImageNameSubstitutor.instance().apply(TINY_IMAGE).asCanonicalNameString(); - - checkAndPullImage(client, tinyImage); - CreateContainerCmd createContainerCmd = client.createContainerCmd(tinyImage) - .withLabels(DEFAULT_LABELS); + 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(); @@ -369,28 +375,6 @@ private T runInsideDocker(DockerClient client, Consumer } } - @VisibleForTesting - static class DiskSpaceUsage { - Optional availableMB = Optional.empty(); - Optional usedPercent = Optional.empty(); - } - - @VisibleForTesting - DiskSpaceUsage parseAvailableDiskSpace(String dfOutput) { - DiskSpaceUsage df = new DiskSpaceUsage(); - String[] lines = dfOutput.split("\n"); - for (String line : lines) { - String[] fields = line.split("\\s+"); - if (fields.length > 5 && fields[5].equals("/")) { - long availableKB = Long.parseLong(fields[3]); - df.availableMB = Optional.of(availableKB / 1024L); - df.usedPercent = Optional.of(Integer.valueOf(fields[4].replace("%", ""))); - break; - } - } - return df; - } - /** * @return the docker API version of the daemon that we have connected to */ @@ -403,8 +387,7 @@ public String getActiveApiVersion() { * @return the docker execution driver of the daemon that we have connected to */ public String getActiveExecutionDriver() { - client(); - return activeExecutionDriver; + return getInfo().getExecutionDriver(); } /** @@ -414,4 +397,9 @@ public String getActiveExecutionDriver() { public boolean isUsing(Class providerStrategyClass) { return strategy != null && providerStrategyClass.isAssignableFrom(this.strategy.getClass()); } + + @UnstableAPI + public Info getInfo() { + return getOrInitializeStrategy().getInfo(); + } } diff --git a/core/src/main/java/org/testcontainers/LazyDockerClient.java b/core/src/main/java/org/testcontainers/LazyDockerClient.java deleted file mode 100644 index 49ebf9330a7..00000000000 --- a/core/src/main/java/org/testcontainers/LazyDockerClient.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.testcontainers; - -import com.github.dockerjava.api.DockerClient; -import lombok.ToString; -import lombok.experimental.Delegate; - -@ToString -enum LazyDockerClient implements DockerClient { - - INSTANCE; - - @Delegate - final DockerClient getDockerClient() { - return DockerClientFactory.instance().client(); - } -} diff --git a/core/src/main/java/org/testcontainers/Testcontainers.java b/core/src/main/java/org/testcontainers/Testcontainers.java index 0f83725c55b..ab567e1fda7 100644 --- a/core/src/main/java/org/testcontainers/Testcontainers.java +++ b/core/src/main/java/org/testcontainers/Testcontainers.java @@ -1,12 +1,11 @@ package org.testcontainers; -import java.util.Map; -import java.util.Map.Entry; - import lombok.experimental.UtilityClass; - import org.testcontainers.containers.PortForwardingContainer; +import java.util.Map; +import java.util.Map.Entry; + @UtilityClass public class Testcontainers { @@ -15,10 +14,10 @@ public void exposeHostPorts(int... ports) { PortForwardingContainer.INSTANCE.exposeHostPort(port); } } - + public void exposeHostPorts(Map ports) { - for(Entry entry : ports.entrySet()) { - PortForwardingContainer.INSTANCE.exposeHostPort(entry.getKey(), entry.getValue()); - } + 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 index 21fd2c3176c..01221738dd4 100644 --- a/core/src/main/java/org/testcontainers/UnstableAPI.java +++ b/core/src/main/java/org/testcontainers/UnstableAPI.java @@ -11,11 +11,7 @@ * a stable API. */ @Retention(RetentionPolicy.SOURCE) -@Target({ - ElementType.TYPE, - ElementType.METHOD, - ElementType.FIELD, -}) +@Target({ ElementType.TYPE, ElementType.METHOD, ElementType.FIELD }) @Documented public @interface UnstableAPI { } 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 index 2fb953b4aee..81525710700 100644 --- a/core/src/main/java/org/testcontainers/containers/ComposeServiceWaitStrategyTarget.java +++ b/core/src/main/java/org/testcontainers/containers/ComposeServiceWaitStrategyTarget.java @@ -1,11 +1,11 @@ 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.DockerClientFactory; import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; import java.util.ArrayList; @@ -13,7 +13,6 @@ import java.util.List; import java.util.Map; - /** * Class to provide a wait strategy target for services started through docker-compose */ @@ -21,14 +20,24 @@ 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 = DockerClientFactory.instance().client().inspectContainerCmd(getContainerId()).exec(); - ComposeServiceWaitStrategyTarget(Container container, GenericContainer proxyContainer, - @NonNull 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); diff --git a/core/src/main/java/org/testcontainers/containers/Container.java b/core/src/main/java/org/testcontainers/containers/Container.java index 9c1cb7f8034..abea7fef576 100644 --- a/core/src/main/java/org/testcontainers/containers/Container.java +++ b/core/src/main/java/org/testcontainers/containers/Container.java @@ -1,17 +1,16 @@ package org.testcontainers.containers; -import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.model.Bind; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.NonNull; import lombok.Value; -import org.testcontainers.DockerClientFactory; -import org.testcontainers.images.ImagePullPolicy; import org.testcontainers.containers.output.OutputFrame; import org.testcontainers.containers.startupcheck.StartupCheckStrategy; import org.testcontainers.containers.traits.LinkableContainer; 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; @@ -24,7 +23,6 @@ import java.util.function.Function; public interface Container> extends LinkableContainer, ContainerState { - /** * @return a reference to this container instance, cast to the expected generic type. */ @@ -39,8 +37,11 @@ default SELF self() { @Value @AllArgsConstructor(access = AccessLevel.MODULE) class ExecResult { + int exitCode; + String stdout; + String stderr; } @@ -76,9 +77,11 @@ class ExecResult { * @param hostPath the file system path on the host * @param containerPath the file system path inside the container * @param mode the bind mode + * @deprecated use {@link GenericContainer#withCopyToContainer(Transferable, String)} */ + @Deprecated default void addFileSystemBind(final String hostPath, final String containerPath, final BindMode mode) { - addFileSystemBind(hostPath, containerPath, mode, SelinuxContext.NONE); + addFileSystemBind(hostPath, containerPath, mode, SelinuxContext.SHARED); } /** @@ -89,7 +92,9 @@ default void addFileSystemBind(final String hostPath, final String containerPath * @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); /** @@ -133,7 +138,9 @@ default void addFileSystemBind(final String hostPath, final String containerPath * @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); } @@ -145,7 +152,9 @@ default SELF withFileSystemBind(String hostPath, String containerPath) { * @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); /** @@ -169,11 +178,23 @@ default SELF withFileSystemBind(String hostPath, String containerPath) { * 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 conatiner to which the files / folders to be copied + * @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. * @@ -284,9 +305,15 @@ default SELF withEnv(String key, Function, String> mapper) { * @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)} */ - default SELF withClasspathResourceMapping(final String resourcePath, final String containerPath, final BindMode mode) { - withClasspathResourceMapping(resourcePath, containerPath, mode, SelinuxContext.NONE); + @Deprecated + default SELF withClasspathResourceMapping( + final String resourcePath, + final String containerPath, + final BindMode mode + ) { + withClasspathResourceMapping(resourcePath, containerPath, mode, SelinuxContext.SHARED); return self(); } @@ -299,8 +326,15 @@ default SELF withClasspathResourceMapping(final String resourcePath, final Strin * @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)} */ - SELF withClasspathResourceMapping(String resourcePath, String containerPath, BindMode mode, SelinuxContext selinuxContext); + @Deprecated + SELF withClasspathResourceMapping( + String resourcePath, + String containerPath, + BindMode mode, + SelinuxContext selinuxContext + ); /** * Set the duration of waiting time until container treated as started. @@ -365,7 +399,9 @@ default SELF withClasspathResourceMapping(final String resourcePath, final Strin * 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(); /** @@ -374,7 +410,7 @@ default SELF withClasspathResourceMapping(final String resourcePath, final Strin * @param consumer consumer that the frames should be sent to */ default void followOutput(Consumer consumer) { - LogUtils.followOutput(DockerClientFactory.instance().client(), getContainerId(), consumer); + LogUtils.followOutput(getDockerClient(), getContainerId(), consumer); } /** @@ -385,10 +421,9 @@ default void followOutput(Consumer consumer) { * @param types types that should be followed (one or both of STDOUT, STDERR) */ default void followOutput(Consumer consumer, OutputFrame.OutputType... types) { - LogUtils.followOutput(DockerClientFactory.instance().client(), getContainerId(), consumer, types); + LogUtils.followOutput(getDockerClient(), getContainerId(), consumer, types); } - /** * Attach an output consumer at container startup, enabling stdout and stderr to be followed, waited on, etc. *

@@ -424,8 +459,6 @@ default void followOutput(Consumer consumer, OutputFrame.OutputType @Deprecated Map getLinkedContainers(); - DockerClient getDockerClient(); - void setExposedPorts(List exposedPorts); void setPortBindings(List portBindings); 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/ContainerState.java b/core/src/main/java/org/testcontainers/containers/ContainerState.java index ad96aeb808c..e19f7a85310 100644 --- a/core/src/main/java/org/testcontainers/containers/ContainerState.java +++ b/core/src/main/java/org/testcontainers/containers/ContainerState.java @@ -12,6 +12,7 @@ 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; @@ -21,12 +22,12 @@ import org.testcontainers.utility.MountableFile; import org.testcontainers.utility.ThrowingFunction; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; 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; @@ -36,19 +37,24 @@ 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). * @@ -102,7 +108,9 @@ default boolean isHealthy() { 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."); + 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()); @@ -111,8 +119,14 @@ default boolean isHealthy() { } } + /** + * Inspects the container and returns up-to-date inspection response. + * + * @return up-to-date container inspect response + * @see #getContainerInfo() + */ default InspectContainerResponse getCurrentContainerInfo() { - return DockerClientFactory.instance().client().inspectContainerCmd(getContainerId()).exec(); + return getDockerClient().inspectContainerCmd(getContainerId()).exec(); } /** @@ -132,13 +146,22 @@ default Integer getFirstMappedPort() { /** * Get the actual mapped port for a given port exposed by the container. - * Should be used in conjunction with {@link #getHost()}. + * 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"); + 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(); @@ -176,21 +199,23 @@ default List getPortBindings() { * @return the bound port numbers */ default List getBoundPortNumbers() { - return getPortBindings().stream() + 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(DockerClientFactory.instance().client(), getContainerId()); + return LogUtils.getOutput(getDockerClient(), getContainerId()); } /** @@ -198,7 +223,7 @@ default String getLogs() { * @return all log output from the container from start until the current instant */ default String getLogs(OutputFrame.OutputType... types) { - return LogUtils.getOutput(DockerClientFactory.instance().client(), getContainerId(), types); + return LogUtils.getOutput(getDockerClient(), getContainerId(), types); } /** @@ -209,7 +234,10 @@ default String getContainerId() { } /** + * Returns the container inspect response. The response might be cached/outdated. + * * @return the container info + * @see #getCurrentContainerInfo() */ InspectContainerResponse getContainerInfo(); @@ -217,19 +245,70 @@ default String getContainerId() { * Run a command inside a running container, as though using "docker exec", and interpreting * the output as UTF8. *

- * @see ExecInContainerPattern#execInContainer(com.github.dockerjava.api.command.InspectContainerResponse, String...) + * @see #execInContainer(Charset, String...) */ - default Container.ExecResult execInContainer(String... command) throws UnsupportedOperationException, IOException, InterruptedException { + 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(com.github.dockerjava.api.command.InspectContainerResponse, Charset, String...) + * @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)} */ - default Container.ExecResult execInContainer(Charset outputCharset, String... command) throws UnsupportedOperationException, IOException, InterruptedException { - return ExecInContainerPattern.execInContainer(getContainerInfo(), outputCharset, command); + @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); } /** @@ -244,7 +323,9 @@ default void copyFileToContainer(MountableFile mountableFile, String containerPa 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"); + 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); @@ -258,26 +339,37 @@ default void copyFileToContainer(MountableFile mountableFile, String containerPa * @param transferable file which is copied into the container * @param containerPath destination path inside the container */ - @SneakyThrows(IOException.class) + @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 ( - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - TarArchiveOutputStream tarArchive = new TarArchiveOutputStream(byteArrayOutputStream) + PipedOutputStream pipedOutputStream = new PipedOutputStream(); + PipedInputStream pipedInputStream = new PipedInputStream(pipedOutputStream); + TarArchiveOutputStream tarArchive = new TarArchiveOutputStream(pipedOutputStream) ) { - tarArchive.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); + Thread thread = new Thread(() -> { + try { + tarArchive.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); + tarArchive.setBigNumberMode(TarArchiveOutputStream.BIGNUMBER_POSIX); - transferable.transferTo(tarArchive, containerPath); - tarArchive.finish(); + transferable.transferTo(tarArchive, containerPath); + } finally { + IOUtils.closeQuietly(tarArchive); + } + }); - DockerClientFactory.instance().client() + thread.start(); + + getDockerClient() .copyArchiveToContainerCmd(getContainerId()) - .withTarInputStream(new ByteArrayInputStream(byteArrayOutputStream.toByteArray())) + .withTarInputStream(pipedInputStream) .withRemotePath("/") .exec(); + + thread.join(); } } @@ -289,13 +381,17 @@ default void copyFileToContainer(Transferable transferable, String containerPath * @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; + 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; + } } - }); + ); } /** @@ -305,12 +401,12 @@ default void copyFileFromContainer(String containerPath, String destinationPath) * @param function function that takes InputStream of the copied file */ @SneakyThrows - default T copyFileFromContainer(String containerPath, ThrowingFunction function) { + 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 = DockerClientFactory.instance().client(); + DockerClient dockerClient = getDockerClient(); try ( InputStream inputStream = dockerClient.copyArchiveFromContainerCmd(getContainerId(), containerPath).exec(); TarArchiveInputStream tarInputStream = new TarArchiveInputStream(inputStream) 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 3d58ad53b3b..bfdaf169dd5 100644 --- a/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java +++ b/core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java @@ -1,47 +1,19 @@ package org.testcontainers.containers; -import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.model.Container; -import com.github.dockerjava.core.LocalDirectorySSLConfig; -import com.github.dockerjava.transport.SSLConfig; 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.collect.Sets; -import com.google.common.util.concurrent.Uninterruptibles; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang.StringUtils; -import org.apache.commons.lang.SystemUtils; -import org.junit.runner.Description; -import org.junit.runners.model.Statement; -import org.slf4j.Logger; -import org.testcontainers.DockerClientFactory; +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.containers.wait.strategy.Wait; -import org.testcontainers.containers.wait.strategy.WaitAllStrategy; import org.testcontainers.containers.wait.strategy.WaitStrategy; -import org.testcontainers.dockerclient.TransportConfig; import org.testcontainers.lifecycle.Startable; -import org.testcontainers.utility.AuditLogger; import org.testcontainers.utility.Base58; -import org.testcontainers.utility.CommandLine; import org.testcontainers.utility.DockerImageName; -import org.testcontainers.utility.DockerLoggerFactory; -import org.testcontainers.utility.LogUtils; -import org.testcontainers.utility.MountableFile; -import org.testcontainers.utility.PathUtils; -import org.testcontainers.utility.ResourceReaper; -import org.zeroturnaround.exec.InvalidExitValueException; -import org.zeroturnaround.exec.ProcessExecutor; -import org.zeroturnaround.exec.stream.slf4j.Slf4jStream; import java.io.File; import java.time.Duration; -import java.util.AbstractMap.SimpleEntry; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -49,51 +21,27 @@ 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.ConcurrentHashMap; -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 static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.base.Preconditions.checkNotNull; -import static com.google.common.base.Strings.isNullOrEmpty; -import static java.util.stream.Collectors.joining; -import static java.util.stream.Collectors.toList; -import static org.testcontainers.containers.BindMode.READ_WRITE; /** * Container which launches Docker Compose, for the purposes of launching a defined set of containers. */ @Slf4j -public class DockerComposeContainer> extends FailureDetectingExternalResource implements Startable { +public class DockerComposeContainer> implements Startable { - /** - * Random identifier which will become part of spawned containers names, so we can shut them down - */ - private final String identifier; - private final List composeFiles; - private Set parsedComposeFiles; private final 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 String project; + private Set options = new HashSet<>(); - private final AtomicInteger nextAmbassadorPort = new AtomicInteger(2000); - private final Map> ambassadorPortMappings = new ConcurrentHashMap<>(); - private final Map serviceInstanceMap = new ConcurrentHashMap<>(); - private final Map waitStrategyMap = new ConcurrentHashMap<>(); - private final SocatContainer ambassadorContainer = new SocatContainer(); - private final Map>> logConsumers = new ConcurrentHashMap<>(); + private boolean tailChildContainers; private static final Object MUTEX = new Object(); @@ -104,96 +52,151 @@ public class DockerComposeContainer> e * necessarily to containers that are spawned by Compose itself) */ private Map env = new HashMap<>(); + private RemoveImages removeImages; - @Deprecated - public DockerComposeContainer(File composeFile, String identifier) { - this(identifier, composeFile); - } + private boolean removeVolumes = true; - public DockerComposeContainer(File... composeFiles) { - this(Arrays.asList(composeFiles)); - } + public static final String COMPOSE_EXECUTABLE = SystemUtils.IS_OS_WINDOWS ? "docker-compose.exe" : "docker-compose"; - public DockerComposeContainer(List composeFiles) { - this(Base58.randomString(6).toLowerCase(), composeFiles); - } + private final ComposeDelegate composeDelegate; - public DockerComposeContainer(String identifier, File... composeFiles) { - this(identifier, Arrays.asList(composeFiles)); + 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)); } - public DockerComposeContainer(String identifier, List 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); + } - this.composeFiles = composeFiles; - this.parsedComposeFiles = composeFiles.stream().map(ParsedDockerComposeFile::new).collect(Collectors.toSet()); + /** + * 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)); + } - // Use a unique identifier so that containers created for this compose environment can be identified - this.identifier = identifier; - this.project = randomProjectId(); + /** + * 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)); + } - this.dockerClient = DockerClientFactory.instance().client(); + /** + * 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(); } - @Override - @Deprecated - public Statement apply(Statement base, Description description) { - return super.apply(base, description); + /** + * Use the new constructor {@link #DockerComposeContainer(DockerImageName image, String identifier, File composeFile)} + */ + public DockerComposeContainer(File composeFile, String identifier) { + this(identifier, composeFile); + this.localCompose = true; } - @Override - @Deprecated - public void starting(Description description) { - start(); + /** + * Use the new constructor {@link #DockerComposeContainer(DockerImageName image, List composeFiles)} + */ + public DockerComposeContainer(File... composeFiles) { + this(Arrays.asList(composeFiles)); + this.localCompose = true; } - @Override + /** + * Use the new constructor {@link #DockerComposeContainer(DockerImageName image, List composeFiles)} + */ @Deprecated - protected void succeeded(Description description) { + public DockerComposeContainer(List composeFiles) { + this(Base58.randomString(6).toLowerCase(), composeFiles); + this.localCompose = true; } - @Override - @Deprecated - protected void failed(Throwable e, Description description) { + /** + * 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; } - @Override - @Deprecated - public void finished(Description description) { - stop(); + /** + * Use the new constructor {@link #DockerComposeContainer(DockerImageName image, String identifier, List composeFiles)} + */ + public DockerComposeContainer(String identifier, List composeFiles) { + 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 public void start() { synchronized (MUTEX) { - registerContainersForShutdown(); + this.composeDelegate.registerContainersForShutdown(); if (pull) { try { - pullImages(); + this.composeDelegate.pullImages(); } catch (ContainerLaunchException e) { log.warn("Exception while pulling images, using local images if available", e); } } - createServices(); - startAmbassadorContainers(); - waitUntilServiceStarted(); + 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 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 - parsedComposeFiles.stream() - .flatMap(it -> it.getDependencyImageNames().stream()) - .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); - DockerClientFactory.instance().checkAndPullImage(dockerClient, imageName); - } 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()); - } - }); + @VisibleForTesting + List listChildContainers() { + return this.composeDelegate.listChildContainers(); } public SELF withServices(@NonNull String... services) { @@ -201,215 +204,53 @@ public SELF withServices(@NonNull String... services) { return self(); } - private void createServices() { - // 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(joining(" ")); - - // Apply scaling for the services specified using `withScaledService` - final String scalingOptions = scalingPreferences.entrySet().stream() - .map(entry -> "--scale " + entry.getKey() + "=" + entry.getValue()) - .distinct() - .collect(joining(" ")); - - String command = optionsAsString() + "up -d"; - - if (build) { - command += " --build"; - } - - if (!isNullOrEmpty(scalingOptions)) { - command += " " + scalingOptions; - } - - if (!isNullOrEmpty(serviceNameArgs)) { - command += " " + serviceNameArgs; - } - - // Run the docker-compose container, which starts up the services - runWithCompose(command); - } - - private String optionsAsString() { - String optionsString = options - .stream() - .collect(joining(" ")); - if (optionsString.length() !=0 ) { - // 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 StringUtils.EMPTY; - } - } - - private void waitUntilServiceStarted() { - listChildContainers().forEach(this::createServiceInstance); - - 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) { - String serviceName = getServiceNameFromContainer(container); - final ComposeServiceWaitStrategyTarget containerInstance = new ComposeServiceWaitStrategyTarget(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(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", containerName, containerNumber); - } - - private void runWithCompose(String cmd) { - checkNotNull(composeFiles); - checkArgument(!composeFiles.isEmpty(), "No docker compose file have been provided"); - - final DockerCompose dockerCompose; - if (localCompose) { - dockerCompose = new LocalDockerCompose(composeFiles, project); - } else { - dockerCompose = new ContainerisedDockerCompose(composeFiles, project); - } - - dockerCompose - .withCommand(cmd) - .withEnv(env) - .invoke(); - } - - private void registerContainersForShutdown() { - ResourceReaper.instance().registerFilterForCleanup(Arrays.asList( - new SimpleEntry<>("label", "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(toList()); - } - - private void startAmbassadorContainers() { - if (!ambassadorPortMappings.isEmpty()) { - ambassadorContainer.start(); - } - } - @Override public void stop() { synchronized (MUTEX) { try { - // shut down the ambassador container - ambassadorContainer.stop(); + this.composeDelegate.getAmbassadorContainer().stop(); // Kill the services using docker-compose - String cmd = "down -v"; + String cmd = ComposeCommand.getDownCommand(ComposeDelegate.ComposeVersion.V1, this.options); + + if (removeVolumes) { + cmd += " -v"; + } if (removeImages != null) { cmd += " --rmi " + removeImages.dockerRemoveImagesType(); } - runWithCompose(cmd); - + this.composeDelegate.runWithCompose(this.localCompose, cmd, this.env, this.filesInDirectory); } finally { - project = randomProjectId(); + this.composeDelegate.clear(); + this.project = this.composeDelegate.randomProjectId(); } } } public SELF withExposedService(String serviceName, int servicePort) { - return withExposedService(serviceName, servicePort, Wait.defaultWaitStrategy()); + this.composeDelegate.withExposedService(serviceName, servicePort, Wait.defaultWaitStrategy()); + return self(); } public DockerComposeContainer withExposedService(String serviceName, int instance, int servicePort) { return withExposedService(serviceName + "_" + instance, servicePort); } - public DockerComposeContainer withExposedService(String serviceName, int instance, int servicePort, WaitStrategy waitStrategy) { - return withExposedService(serviceName + "_" + instance, servicePort, waitStrategy); + 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) { - - 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 + "_" + serviceInstanceName), serviceInstanceName); - addWaitStrategy(serviceInstanceName, waitStrategy); + this.composeDelegate.withExposedService(serviceName, servicePort, waitStrategy); return self(); } - private String getServiceInstanceName(String serviceName) { - String serviceInstanceName = serviceName; - if (!serviceInstanceName.matches(".*_[0-9]+")) { - serviceInstanceName += "_1"; // 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 an long timeout, because timeouts should be handled by the inner strategies. - */ - private void addWaitStrategy(String serviceInstanceName, @NonNull WaitStrategy waitStrategy) { - final WaitAllStrategy waitAllStrategy = waitStrategyMap.computeIfAbsent(serviceInstanceName, __ -> - new WaitAllStrategy(WaitAllStrategy.Mode.WITH_MAXIMUM_OUTER_TIMEOUT).withStartupTimeout(Duration.ofMinutes(30))); - waitAllStrategy.withStrategy(waitStrategy); - } - /** * Specify the {@link WaitStrategy} to use to determine if the container is ready. * @@ -419,8 +260,8 @@ private void addWaitStrategy(String serviceInstanceName, @NonNull WaitStrategy w * @see org.testcontainers.containers.wait.strategy.Wait#defaultWaitStrategy() */ public SELF waitingFor(String serviceName, @NonNull WaitStrategy waitStrategy) { - String serviceInstanceName = getServiceInstanceName(serviceName); - addWaitStrategy(serviceInstanceName, waitStrategy); + String serviceInstanceName = this.composeDelegate.getServiceInstanceName(serviceName); + this.composeDelegate.addWaitStrategy(serviceInstanceName, waitStrategy); return self(); } @@ -435,7 +276,7 @@ public SELF waitingFor(String serviceName, @NonNull WaitStrategy waitStrategy) { * @return a host IP address or hostname that can be used for accessing the service container. */ public String getServiceHost(String serviceName, Integer servicePort) { - return ambassadorContainer.getHost(); + return this.composeDelegate.getServiceHost(); } /** @@ -449,20 +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) { - Map portMap = 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)); - } + return this.composeDelegate.getServicePort(serviceName, servicePort); } public SELF withScaledService(String serviceBaseName, int numInstances) { scalingPreferences.put(serviceBaseName, numInstances); - return self(); } @@ -476,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. * @@ -516,10 +338,7 @@ public SELF withTailChildContainers(boolean tailChildContainers) { * @return this instance, for chaining */ public SELF 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); + this.composeDelegate.withLogConsumer(serviceName, consumer); return self(); } @@ -553,22 +372,44 @@ public SELF withRemoveImages(RemoveImages removeImages) { return self(); } + /** + * Remove volumes after containers shut down. + * + * @param removeVolumes whether volumes are to be removed. + * @return this instance, for chaining. + */ + public SELF withRemoveVolumes(boolean removeVolumes) { + this.removeVolumes = removeVolumes; + return self(); + } + + /** + * 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(); + } + + public SELF withCopyFilesInContainer(String... fileCopyInclusions) { + this.filesInDirectory = Arrays.asList(fileCopyInclusions); + return self(); + } + public Optional getContainerByServiceName(String serviceName) { - return Optional.ofNullable(serviceInstanceMap.get(serviceName)); + return this.composeDelegate.getContainerByServiceName(serviceName); } private void followLogs(String containerId, Consumer consumer) { - LogUtils.followOutput(DockerClientFactory.instance().client(), containerId, consumer); + this.followLogs(containerId, consumer); } private SELF self() { return (SELF) this; } - private String randomProjectId() { - return identifier + Base58.randomString(6).toLowerCase(); - } - public enum RemoveImages { /** * Remove all images used by any service. @@ -591,197 +432,3 @@ public String dockerRemoveImagesType() { } } } - -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(); -} - -/** - * Use Docker Compose container. - */ -class ContainerisedDockerCompose extends GenericContainer implements DockerCompose { - - public static final char UNIX_PATH_SEPERATOR = ':'; - public static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("docker/compose:1.24.1"); - - public ContainerisedDockerCompose(List composeFiles, String identifier) { - - super(DEFAULT_IMAGE_NAME); - 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(toList()); - final String composeFileEnvVariableValue = Joiner.on(UNIX_PATH_SEPERATOR).join(absoluteDockerComposeFiles); // we always need the UNIX path separator - logger().debug("Set env COMPOSE_FILE={}", composeFileEnvVariableValue); - addEnv(ENV_COMPOSE_FILE, composeFileEnvVariableValue); - addFileSystemBind(pwd, containerPwd, READ_WRITE); - - // 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", READ_WRITE); - addEnv("DOCKER_HOST", "unix:///docker.sock"); - setStartupCheckStrategy(new IndefiniteWaitOneShotStartupCheckStrategy()); - setWorkingDirectory(containerPwd); - } - - @Override - public void invoke() { - 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"); - - AuditLogger.doComposeLog(this.getCommandParts(), this.getEnv()); - - final Integer exitCode = this.dockerClient.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(this.getCommandParts(), ' ')); - } - } - - private String convertToUnixFilesystemPath(String path) { - return SystemUtils.IS_OS_WINDOWS - ? PathUtils.createMinGWPath(path).substring(1) - : path; - } -} - -/** - * Use local Docker Compose binary, if present. - */ -class LocalDockerCompose implements DockerCompose { - /** - * Executable name for Docker Compose. - */ - private static final String COMPOSE_EXECUTABLE = SystemUtils.IS_OS_WINDOWS ? "docker-compose.exe" : "docker-compose"; - - private final List composeFiles; - private final String identifier; - private String cmd = ""; - private Map env = new HashMap<>(); - - public LocalDockerCompose(List composeFiles, String identifier) { - 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; - } - - @VisibleForTesting - static boolean executableExists() { - return CommandLine.executableExists(COMPOSE_EXECUTABLE); - } - - @Override - public void invoke() { - // bail out early - if (!executableExists()) { - 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); - - String dockerHost = System.getenv("DOCKER_HOST"); - if (dockerHost == null) { - 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."); - } - } - 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( - 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(COMPOSE_EXECUTABLE + " " + 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(COMPOSE_EXECUTABLE); - } -} 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 index 9cd68d48ffb..1ed23224e5e 100644 --- a/core/src/main/java/org/testcontainers/containers/ExecInContainerPattern.java +++ b/core/src/main/java/org/testcontainers/containers/ExecInContainerPattern.java @@ -1,6 +1,7 @@ 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; @@ -14,6 +15,10 @@ 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 @@ -22,39 +27,169 @@ @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 #execInContainer(InspectContainerResponse, Charset, String...) + * @see #execInContainerWithUser(DockerClient, InspectContainerResponse, String, String...) */ - public Container.ExecResult execInContainer(InspectContainerResponse containerInfo, String... command) - throws UnsupportedOperationException, IOException, InterruptedException { - return execInContainer(containerInfo, Charset.forName("UTF-8"), command); + 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 though using "docker exec". + * 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)} */ - public Container.ExecResult execInContainer(InspectContainerResponse containerInfo, Charset outputCharset, String... command) - throws UnsupportedOperationException, IOException, InterruptedException { + @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\"."); - + "Your docker daemon is running the \"lxc\" driver, which doesn't support \"docker exec\"." + ); } if (!isRunning(containerInfo)) { @@ -64,11 +199,37 @@ public Container.ExecResult execInContainer(InspectContainerResponse containerIn String containerId = containerInfo.getId(); String containerName = containerInfo.getName(); - DockerClient dockerClient = DockerClientFactory.instance().client(); - + String[] command = execConfig.getCommand(); log.debug("{}: Running \"exec\" command: {}", containerName, String.join(" ", command)); - final ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId) - .withAttachStdout(true).withAttachStderr(true).withCmd(command).exec(); + 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(); @@ -79,12 +240,13 @@ public Container.ExecResult execInContainer(InspectContainerResponse containerIn dockerClient.execStartCmd(execCreateCmdResponse.getId()).exec(callback).awaitCompletion(); } - Integer exitCode = dockerClient.inspectExecCmd(execCreateCmdResponse.getId()).exec().getExitCode(); + int exitCode = dockerClient.inspectExecCmd(execCreateCmdResponse.getId()).exec().getExitCodeLong().intValue(); final Container.ExecResult result = new Container.ExecResult( exitCode, stdoutConsumer.toString(outputCharset), - stderrConsumer.toString(outputCharset)); + stderrConsumer.toString(outputCharset) + ); log.trace("{}: stdout: {}", containerName, result.getStdout()); log.trace("{}: stderr: {}", containerName, result.getStderr()); 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 29a032d320b..00000000000 --- a/core/src/main/java/org/testcontainers/containers/FailureDetectingExternalResource.java +++ /dev/null @@ -1,56 +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(); - - try { - starting(description); - 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 6477c28042e..db610ad57c2 100644 --- a/core/src/main/java/org/testcontainers/containers/FixedHostPortGenericContainer.java +++ b/core/src/main/java/org/testcontainers/containers/FixedHostPortGenericContainer.java @@ -11,7 +11,8 @@ *

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. @@ -28,7 +29,6 @@ public FixedHostPortGenericContainer(@NotNull String dockerImageName) { * @return this container */ public SELF withFixedExposedPort(int hostPort, int containerPort) { - return withFixedExposedPort(hostPort, containerPort, InternetProtocol.TCP); } @@ -40,7 +40,6 @@ public SELF withFixedExposedPort(int hostPort, int containerPort) { * @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 4536a70789c..4d3778c63d1 100644 --- a/core/src/main/java/org/testcontainers/containers/GenericContainer.java +++ b/core/src/main/java/org/testcontainers/containers/GenericContainer.java @@ -1,8 +1,5 @@ package org.testcontainers.containers; -import static com.google.common.collect.Lists.newArrayList; -import static org.testcontainers.utility.CommandLine.runShellCommand; - import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.SerializationFeature; @@ -16,62 +13,26 @@ 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 java.io.File; -import java.io.IOException; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.UndeclaredThrowableException; -import java.nio.charset.Charset; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.Duration; -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.Optional; -import java.util.Set; -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 lombok.AccessLevel; import lombok.Data; +import lombok.Getter; import lombok.NonNull; import lombok.Setter; import lombok.SneakyThrows; -import org.apache.commons.io.FileUtils; -import org.apache.commons.lang.StringUtils; -import org.apache.commons.lang.SystemUtils; +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.junit.runners.model.Statement; -import org.rnorth.ducttape.ratelimits.RateLimiter; -import org.rnorth.ducttape.ratelimits.RateLimiterBuilder; import org.rnorth.ducttape.unreliables.Unreliables; -import org.rnorth.visibleassertions.VisibleAssertions; import org.slf4j.Logger; import org.testcontainers.DockerClientFactory; import org.testcontainers.UnstableAPI; @@ -83,30 +44,65 @@ 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.images.builder.Transferable; import org.testcontainers.lifecycle.Startable; import org.testcontainers.lifecycle.Startables; -import org.testcontainers.lifecycle.TestDescription; -import org.testcontainers.lifecycle.TestLifecycleAware; 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.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.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 org.awaitility.Awaitility.await; + /** * Base class for that allows a container to be launched and controlled. */ @Data public class GenericContainer> - extends FailureDetectingExternalResource - implements Container, AutoCloseable, WaitStrategyTarget, Startable { - - private static final Charset UTF8 = Charset.forName("UTF-8"); + implements Container, AutoCloseable, WaitStrategyTarget, Startable { public static final int CONTAINER_RUNNING_TIMEOUT_SEC = 30; @@ -119,43 +115,12 @@ public class GenericContainer> /* * Default settings */ - @NonNull - private LinkedHashSet exposedPorts = new LinkedHashSet<>(); - - @NonNull - private List portBindings = new ArrayList<>(); - @NonNull private List extraHosts = new ArrayList<>(); - @NonNull - private String networkMode; - - @NonNull - private Network network; - - @NonNull - private List networkAliases = new ArrayList<>(Arrays.asList( - "tc-" + Base58.randomString(8) - )); - @NonNull private RemoteDockerImage image; - @NonNull - private Map env = new HashMap<>(); - - @NonNull - private Map labels = new HashMap<>(); - - @NonNull - private String[] commandParts = new String[0]; - - @NonNull - private List binds = new ArrayList<>(); - - private boolean privilegedMode; - @NonNull private List volumesFroms = new ArrayList<>(); @@ -181,8 +146,15 @@ public class GenericContainer> private Long shmSize; // 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<>(); /** @@ -194,7 +166,6 @@ public class GenericContainer> /** * Set during container startup - * */ @Setter(AccessLevel.NONE) @VisibleForTesting @@ -203,22 +174,17 @@ public class GenericContainer> @Setter(AccessLevel.NONE) private InspectContainerResponse containerInfo; + static WaitStrategy DEFAULT_WAIT_STRATEGY = Wait.defaultWaitStrategy(); + /** * The approach to determine if the container is ready. */ @NonNull - protected org.testcontainers.containers.wait.strategy.WaitStrategy waitStrategy = Wait.defaultWaitStrategy(); + protected WaitStrategy waitStrategy = DEFAULT_WAIT_STRATEGY; private List> logConsumers = new ArrayList<>(); - private final Set> createContainerCmdModifiers = new LinkedHashSet<>(); - 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(); @Nullable private Map tmpFsMapping; @@ -226,17 +192,44 @@ public class GenericContainer> @Setter(AccessLevel.NONE) private boolean shouldBeReused = false; + private boolean hostAccessible = false; + + 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.image = new RemoteDockerImage(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 use {@link #GenericContainer(DockerImageName)} instead */ @Deprecated public GenericContainer() { @@ -244,25 +237,38 @@ public GenericContainer() { } public GenericContainer(@NonNull final String dockerImageName) { - this.setDockerImageName(dockerImageName); + this(new RemoteDockerImage(DockerImageName.parse(dockerImageName))); } public GenericContainer(@NonNull final Future image) { - setImage(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() { - return new ArrayList<>(exposedPorts); + List exposedPorts = new ArrayList<>(); + for (ExposedPort exposedPort : this.containerDef.getExposedPorts()) { + exposedPorts.add(exposedPort.getPort()); + } + return exposedPorts; } @Override public void setExposedPorts(List exposedPorts) { - this.exposedPorts = new LinkedHashSet<>(exposedPorts); + this.containerDef.exposedPorts.clear(); + for (Integer exposedPort : exposedPorts) { + this.containerDef.addExposedTcpPort(exposedPort); + } } /** @@ -300,7 +306,7 @@ public String getContainerId() { * Starts the container using docker, pulling an image if necessary. */ @Override - @SneakyThrows({InterruptedException.class, ExecutionException.class}) + @SneakyThrows({ InterruptedException.class, ExecutionException.class }) public void start() { if (containerId != null) { return; @@ -313,21 +319,31 @@ public void start() { protected void doStart() { try { - configure(); + if (this.waitStrategy != DEFAULT_WAIT_STRATEGY) { + this.containerDef.setWaitStrategy(this.waitStrategy); + } - Instant startedAt = Instant.now(); + configure(); logger().debug("Starting container: {}", getDockerImageName()); AtomicInteger attempt = new AtomicInteger(0); - Unreliables.retryUntilSuccess(startupAttempts, () -> { - logger().debug("Trying to start container: {} (attempt {}/{})", getDockerImageName(), attempt.incrementAndGet(), startupAttempts); - tryStart(startedAt); - 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); + throw new ContainerLaunchException("Container startup failed for image " + getDockerImageName(), e); } } @@ -341,7 +357,7 @@ protected boolean canBeReused() { logger().warn("{} can't be reused because it overrides {}", getClass(), method.getName()); return false; } - } catch (NoSuchMethodException e) { + } catch (NoSuchMethodException | NoClassDefFoundError e) { // ignore } } @@ -349,16 +365,17 @@ protected boolean canBeReused() { return true; } - private void tryStart(Instant startedAt) { + private void tryStart() { try { String dockerImageName = getDockerImageName(); logger().debug("Starting container: {}", dockerImageName); + Instant startedAt = Instant.now(); logger().info("Creating container for image: {}", dockerImageName); CreateContainerCmd createCommand = dockerClient.createContainerCmd(dockerImageName); applyConfiguration(createCommand); - createCommand.getLabels().put(DockerClientFactory.TESTCONTAINERS_LABEL, "true"); + createCommand.getLabels().putAll(DockerClientFactory.DEFAULT_LABELS); boolean reused = false; final boolean reusable; @@ -368,10 +385,9 @@ private void tryStart(Instant startedAt) { } if (TestcontainersConfiguration.getInstance().environmentSupportsReuse()) { - createCommand.getLabels().put( - COPIED_FILES_HASH_LABEL, - Long.toHexString(hashCopiedFiles().getValue()) - ); + createCommand + .getLabels() + .put(COPIED_FILES_HASH_LABEL, Long.toHexString(hashCopiedFiles().getValue())); String hash = hash(createCommand); @@ -387,12 +403,13 @@ private void tryStart(Instant startedAt) { } reusable = true; } else { - logger().warn( - "" + + 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") - ); + Paths.get(System.getProperty("user.home"), ".testcontainers.properties") + ); reusable = false; } } else { @@ -400,7 +417,8 @@ private void tryStart(Instant startedAt) { } if (!reusable) { - createCommand.getLabels().put(DockerClientFactory.TESTCONTAINERS_SESSION_ID_LABEL, DockerClientFactory.SESSION_ID); + //noinspection deprecation + createCommand = ResourceReaper.instance().register(this, createCommand); } if (!reused) { @@ -408,6 +426,8 @@ private void tryStart(Instant startedAt) { // 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()); @@ -415,21 +435,48 @@ private void tryStart(Instant startedAt) { if (!reused) { containerIsCreated(containerId); - logger().info("Starting container with ID: {}", 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); } - logger().info("Container {} is starting: {}", dockerImageName, containerId); - // For all registered output consumers, start following as close to container startup as possible this.logConsumers.forEach(this::followOutput); + // 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 - containerInfo = dockerClient.inspectContainerCmd(containerId).exec(); containerIsStarting(containerInfo, reused); // Wait until the container has reached the desired running state - if (!this.startupCheckStrategy.waitUntilStartupSuccessful(dockerClient, containerId)) { + 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."); @@ -448,25 +495,31 @@ private void tryStart(Instant startedAt) { } if (inspectContainerResponse == null) { - throw new IllegalStateException("Container is removed"); + throw new IllegalStateException("Wait strategy failed. Container is removed", e); } InspectContainerResponse.ContainerState state = inspectContainerResponse.getState(); if (Boolean.TRUE.equals(state.getDead())) { - throw new IllegalStateException("Container is dead"); + throw new IllegalStateException("Wait strategy failed. Container is dead", e); } if (Boolean.TRUE.equals(state.getOOMKilled())) { - throw new IllegalStateException("Container crashed with out-of-memory (OOMKilled)"); + 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("Container crashed: " + error); + throw new IllegalStateException("Wait strategy failed. Container crashed: " + error, e); } if (!Boolean.TRUE.equals(state.getRunning())) { - throw new IllegalStateException("Container exited with code " + state.getExitCode()); + throw new IllegalStateException( + "Wait strategy failed. Container exited with code " + state.getExitCode(), + e + ); } throw e; @@ -488,10 +541,11 @@ private void tryStart(Instant startedAt) { final String containerLogs = getLogs(); if (containerLogs.length() > 0) { - logger().error("Log output from the failed container:\n{}", getLogs()); + 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); @@ -501,39 +555,27 @@ private void tryStart(Instant startedAt) { @VisibleForTesting Checksum hashCopiedFiles() { Checksum checksum = new Adler32(); - copyToFileContainerPathMap.entrySet().stream().sorted(Entry.comparingByValue()).forEach(entry -> { - byte[] pathBytes = entry.getValue().getBytes(); - // Add path to the hash - checksum.update(pathBytes, 0, pathBytes.length); - - File file = new File(entry.getKey().getResolvedPath()); - checksumFile(file, checksum); - }); + 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; } - @VisibleForTesting - @SneakyThrows(IOException.class) - 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); - } - } - @UnstableAPI @SneakyThrows(JsonProcessingException.class) final String hash(CreateContainerCmd createCommand) { DefaultDockerClientConfig dockerClientConfig = DefaultDockerClientConfig.createDefaultConfigBuilder().build(); - byte[] commandJson = dockerClientConfig.getObjectMapper() + byte[] commandJson = dockerClientConfig + .getObjectMapper() .copy() .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) @@ -546,7 +588,8 @@ final String hash(CreateContainerCmd createCommand) { @VisibleForTesting Optional findContainerForReuse(String hash) { // TODO locking - return dockerClient.listContainersCmd() + return dockerClient + .listContainersCmd() .withLabelFilter(ImmutableMap.of(HASH_LABEL, hash)) .withLimit(1) .withStatusFilter(Arrays.asList("running")) @@ -559,8 +602,7 @@ Optional findContainerForReuse(String hash) { /** * Set any custom settings for the create command such as shared memory size. */ - private HostConfig buildHostConfig() { - HostConfig config = new HostConfig(); + private HostConfig buildHostConfig(HostConfig config) { if (shmSize != null) { config.withShmSize(shmSize); } @@ -571,19 +613,28 @@ private HostConfig buildHostConfig() { } private void connectToPortForwardingNetwork(String networkMode) { - PortForwardingContainer.INSTANCE.getNetwork().map(ContainerNetwork::getNetworkID).ifPresent(networkId -> { - if (!Arrays.asList(networkId, "none", "host").contains(networkMode)) { - dockerClient.connectToNetworkCmd().withContainerId(containerId).withNetworkId(networkId).exec(); - } - }); + 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(); + } + } + }); } /** - * Stops the container. + * Kill and remove the container. */ @Override public void stop() { - if (containerId == null) { return; } @@ -621,39 +672,45 @@ 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(DockerClientFactory.TESTCONTAINERS_THREAD_GROUP, () -> { - 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 containerIsCreated(String containerId) {} - @SuppressWarnings({"EmptyMethod", "UnusedParameters"}) - protected void containerIsStarting(InspectContainerResponse containerInfo) { - } + @SuppressWarnings({ "EmptyMethod", "UnusedParameters" }) + protected void containerIsStarting(InspectContainerResponse containerInfo) {} - @SuppressWarnings({"EmptyMethod", "UnusedParameters"}) + @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"}) + @SuppressWarnings({ "EmptyMethod", "UnusedParameters" }) @UnstableAPI protected void containerIsStarted(InspectContainerResponse containerInfo, boolean reused) { containerIsStarted(containerInfo); @@ -664,18 +721,16 @@ protected void containerIsStarted(InspectContainerResponse containerInfo, boolea * 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) { - } + @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) { - } + @SuppressWarnings({ "EmptyMethod", "UnusedParameters" }) + protected void containerIsStopped(InspectContainerResponse containerInfo) {} /** * @return the port on which to check if the container is ready @@ -684,11 +739,13 @@ protected void containerIsStopped(InspectContainerResponse containerInfo) { @Deprecated protected Integer getLivenessCheckPort() { // legacy implementation for backwards compatibility - Iterator exposedPortsIterator = exposedPorts.iterator(); + Iterator exposedPortsIterator = this.containerDef.getExposedPorts().iterator(); if (exposedPortsIterator.hasNext()) { - return getMappedPort(exposedPortsIterator.next()); - } else if (portBindings.size() > 0) { - return Integer.valueOf(PortBinding.parse(portBindings.get(0)).getBinding().getHostPortSpec()); + return getMappedPort(exposedPortsIterator.next().getPort()); + } else if (!this.containerDef.getPortBindings().isEmpty()) { + return Integer.valueOf( + this.containerDef.getPortBindings().iterator().next().getBinding().getHostPortSpec() + ); } else { return null; } @@ -718,56 +775,15 @@ public Set getLivenessCheckPortNumbers() { } private void applyConfiguration(CreateContainerCmd createCommand) { - HostConfig hostConfig = buildHostConfig(); - createCommand.withHostConfig(hostConfig); - - // 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); + this.containerDef.applyTo(createCommand); + buildHostConfig(createCommand.getHostConfig()); - createCommand.withPortBindings(portBindingArray); - - if (commandParts != null) { - createCommand.withCmd(commandParts); - } - - String[] envArray = env.entrySet().stream() - .filter(it -> it.getValue() != null) - .map(it -> it.getKey() + "=" + it.getValue()) - .toArray(String[]::new); - createCommand.withEnv(envArray); - - boolean shouldCheckFileMountingSupport = binds.size() > 0 && !TestcontainersConfiguration.getInstance().isDisableChecks(); - if (shouldCheckFileMountingSupport) { - if (!DockerClientFactory.instance().isFileMountingSupported()) { - VisibleAssertions.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." - ); - } - } - - Bind[] bindsArray = binds.stream() - .toArray(Bind[]::new); - createCommand.withBinds(bindsArray); - - 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 (Entry linkEntries : linkedContainers.entrySet()) { - String alias = linkEntries.getKey(); LinkableContainer linkableContainer = linkEntries.getValue(); @@ -775,9 +791,11 @@ private void applyConfiguration(CreateContainerCmd createCommand) { allLinks.addAll(links); if (allLinks.size() == 0) { - throw new ContainerLaunchException("Aborting attempt to link to container " + - linkableContainer.getContainerName() + - " as it is not running"); + throw new ContainerLaunchException( + "Aborting attempt to link to container " + + linkableContainer.getContainerName() + + " as it is not running" + ); } Set linkedContainerNetworks = findAllNetworksForLinkedContainers(linkableContainer); @@ -788,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(); @@ -799,67 +820,58 @@ private void applyConfiguration(CreateContainerCmd createCommand) { createCommand.withNetworkMode(networkForLinks.get()); } - createCommand.withPublishAllPorts(true); - - PortForwardingContainer.INSTANCE.getNetwork().ifPresent(it -> { - withExtraHost(INTERNAL_HOST_HOSTNAME, it.getIpAddress()); - }); + 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 (network != null) { - createCommand.withNetworkMode(network.getId()); - createCommand.withAliases(this.networkAliases); - } else 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); } - - createContainerCmdModifiers.forEach(hook -> hook.accept(createCommand)); - - Map combinedLabels = new HashMap<>(); - combinedLabels.putAll(labels); - if (createCommand.getLabels() != null) { - combinedLabels.putAll(createCommand.getLabels()); - } - - createCommand.withLabels(combinedLabels); } 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()); + 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()); } private Set findAllNetworksForLinkedContainers(LinkableContainer linkableContainer) { - return 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()); + 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()); } /** * {@inheritDoc} */ @Override - public SELF waitingFor(@NonNull org.testcontainers.containers.wait.strategy.WaitStrategy waitStrategy) { + public SELF waitingFor(@NonNull WaitStrategy waitStrategy) { this.waitStrategy = waitStrategy; return self(); } @@ -870,35 +882,61 @@ public SELF waitingFor(@NonNull org.testcontainers.containers.wait.strategy.Wait * * @return the {@link WaitStrategy} to use */ - protected org.testcontainers.containers.wait.strategy.WaitStrategy getWaitStrategy() { - return waitStrategy; + protected WaitStrategy getWaitStrategy() { + return this.waitStrategy == DEFAULT_WAIT_STRATEGY ? this.containerDef.getWaitStrategy() : this.waitStrategy; } @Override - public void setWaitStrategy(org.testcontainers.containers.wait.strategy.WaitStrategy waitStrategy) { + public void setWaitStrategy(WaitStrategy waitStrategy) { this.waitStrategy = waitStrategy; } /** * Wait until the container has started. The default implementation simply * waits for a port to start listening; other implementations are available - * as implementations of {@link org.testcontainers.containers.wait.strategy.WaitStrategy} + * as implementations of {@link WaitStrategy} * - * @see #waitingFor(org.testcontainers.containers.wait.strategy.WaitStrategy) + * @see #waitingFor(WaitStrategy) */ protected void waitUntilContainerStarted() { - org.testcontainers.containers.wait.strategy.WaitStrategy waitStrategy = getWaitStrategy(); + 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; + } + /** * {@inheritDoc} */ @Override public void setCommand(@NonNull String command) { - this.commandParts = command.split(" "); + this.containerDef.setCommand(command.split(" ")); } /** @@ -906,12 +944,12 @@ 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 env; + return this.containerDef.envVars; } /** @@ -919,19 +957,18 @@ public Map getEnvMap() { */ @Override public List getEnv() { - return env.entrySet().stream() - .map(it -> it.getKey() + "=" + it.getValue()) - .collect(Collectors.toList()); + return this.containerDef.getEnvVars() + .entrySet() + .stream() + .map(it -> it.getKey() + "=" + it.getValue()) + .collect(Collectors.toList()); } @Override public void setEnv(List env) { - this.env = env.stream() - .map(it -> it.split("=")) - .collect(Collectors.toMap( - it -> it[0], - it -> it[1] - )); + this.containerDef.setEnvVars( + env.stream().map(it -> it.split("=")).collect(Collectors.toMap(it -> it[0], it -> it[1])) + ); } /** @@ -939,21 +976,34 @@ public void setEnv(List env) { */ @Override public void addEnv(String key, String value) { - env.put(key, value); + this.containerDef.addEnvVar(key, value); } /** * {@inheritDoc} */ @Override - public void addFileSystemBind(final String hostPath, final String containerPath, final BindMode mode, final SelinuxContext selinuxContext) { + 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 - binds.add(new Bind(hostPath, new Volume(containerPath), mode.accessMode, selinuxContext.selContext)); - + this.containerDef.addBinds( + new Bind(hostPath, new Volume(containerPath), mode.accessMode, selinuxContext.selContext) + ); } else { final MountableFile mountableFile = MountableFile.forHostPath(hostPath); - binds.add(new Bind(mountableFile.getResolvedPath(), new Volume(containerPath), mode.accessMode, selinuxContext.selContext)); + this.containerDef.addBinds( + new Bind( + mountableFile.getResolvedPath(), + new Volume(containerPath), + mode.accessMode, + selinuxContext.selContext + ) + ); } } @@ -990,65 +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); - } - } - - private TestDescription toDescription(Description description) { - return new TestDescription() { - @Override - public String getTestId() { - return description.getDisplayName(); - } - - @Override - public String getFilesystemFriendlyName() { - return description.getClassName() + "-" + description.getMethodName(); - } - }; - } - - @Override - @Deprecated - public Statement apply(Statement base, Description description) { - return super.apply(base, description); - } - - @Override - @Deprecated - protected void starting(Description description) { - if (this instanceof TestLifecycleAware) { - ((TestLifecycleAware) this).beforeTest(toDescription(description)); - } - this.start(); - } - - @Override - @Deprecated - protected void succeeded(Description description) { - if (this instanceof TestLifecycleAware) { - ((TestLifecycleAware) this).afterTest(toDescription(description), Optional.empty()); - } - } - - @Override - @Deprecated - protected void failed(Throwable e, Description description) { - if (this instanceof TestLifecycleAware) { - ((TestLifecycleAware) this).afterTest(toDescription(description), Optional.of(e)); - } - } - - @Override - @Deprecated - protected void finished(Description description) { - this.stop(); + this.containerDef.addExposedTcpPorts(ports); } /** @@ -1056,9 +1053,8 @@ protected void finished(Description description) { */ @Override public SELF withExposedPorts(Integer... ports) { - this.setExposedPorts(newArrayList(ports)); + this.setExposedPorts(Lists.newArrayList(ports)); return self(); - } /** @@ -1087,7 +1083,12 @@ protected void addFixedExposedPort(int hostPort, int containerPort) { * @param protocol */ protected void addFixedExposedPort(int hostPort, int containerPort, InternetProtocol protocol) { - portBindings.add(String.format("%d:%d/%s", hostPort, containerPort, protocol.toDockerNotation())); + 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); } /** @@ -1104,7 +1105,7 @@ public SELF withEnv(String key, String value) { */ @Override public SELF withEnv(Map env) { - env.forEach(this::addEnv); + env.forEach(this.containerDef::addEnvVar); return self(); } @@ -1114,9 +1115,9 @@ public SELF withEnv(Map env) { @Override public SELF withLabel(String key, String value) { if (key.startsWith("org.testcontainers")) { - throw new IllegalArgumentException("The org.testcontainers namespace is reserved for interal use"); + throw new IllegalArgumentException("The org.testcontainers namespace is reserved for internal use"); } - labels.put(key, value); + this.containerDef.addLabel(key, value); return self(); } @@ -1158,19 +1159,19 @@ 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.network = network; + this.containerDef.setNetwork(network); return self(); } @Override public SELF withNetworkAliases(String... aliases) { - Collections.addAll(this.networkAliases, aliases); + this.containerDef.addNetworkAliases(aliases); return self(); } @@ -1184,21 +1185,30 @@ public SELF withImagePullPolicy(ImagePullPolicy imagePullPolicy) { * {@inheritDoc} */ @Override - public SELF withClasspathResourceMapping(final String resourcePath, final String containerPath, final BindMode mode) { - return withClasspathResourceMapping(resourcePath, containerPath, mode, SelinuxContext.NONE); + 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) { + public SELF withClasspathResourceMapping( + final String resourcePath, + final String containerPath, + final BindMode mode, + final SelinuxContext selinuxContext + ) { final MountableFile mountableFile = MountableFile.forClasspathResource(resourcePath); - if (mode == BindMode.READ_ONLY && selinuxContext == SelinuxContext.NONE) { - withCopyFileToContainer(mountableFile, containerPath); - } else { + if (mode == BindMode.READ_WRITE) { addFileSystemBind(mountableFile.getResolvedPath(), containerPath, mode, selinuxContext); + } else { + withCopyFileToContainer(mountableFile, containerPath); } return self(); @@ -1215,7 +1225,7 @@ public SELF withStartupTimeout(Duration startupTimeout) { @Override public SELF withPrivilegedMode(boolean mode) { - this.privilegedMode = mode; + this.containerDef.setPrivilegedMode(mode); return self(); } @@ -1251,10 +1261,22 @@ public SELF withWorkingDirectory(String workDir) { */ @Override 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 SELF withCopyToContainer(Transferable transferable, String containerPath) { + copyToTransferableContainerPathMap.put(transferable, containerPath); + return self(); + } + /** * Get the IP address that this container may be reached on (may not be the local machine). * @@ -1291,6 +1313,7 @@ public String getDockerImageName() { * {@inheritDoc} */ @Override + @Deprecated public String getTestHostIpAddress() { if (DockerMachineClient.instance().isInstalled()) { try { @@ -1299,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" + ); } } @@ -1350,7 +1383,7 @@ public SELF withStartupAttempts(int attempts) { } /** - * Allow low level modifications of {@link CreateContainerCmd} after it was pre-configured in {@link #tryStart(Instant)}. + * 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. * @@ -1358,12 +1391,16 @@ public SELF withStartupAttempts(int attempts) { * @return this */ public SELF withCreateContainerCmdModifier(Consumer modifier) { - createContainerCmdModifiers.add(modifier); + this.createContainerCmdModifiers.add(cmd -> { + modifier.accept(cmd); + return cmd; + }); return self(); } /** * 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 */ @@ -1374,6 +1411,7 @@ public SELF withSharedMemorySize(Long bytes) { /** * First class support for configuring tmpfs + * * @param mapping path and params of tmpfs/mount flag for container * @return this */ @@ -1388,6 +1426,18 @@ public SELF withReuse(boolean reusable) { return self(); } + /** + * 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 + */ + public SELF withAccessToHost(boolean value) { + this.hostAccessible = value; + return self(); + } + @Override public boolean equals(Object o) { return this == o; @@ -1402,4 +1452,76 @@ public int hashCode() { public String getContainerName() { return getContainerInfo().getName(); } + + public Network getNetwork() { + return this.containerDef.getNetwork(); + } + + @Override + public List getBinds() { + return this.containerDef.binds; + } + + @Override + public void setBinds(List binds) { + this.containerDef.setBinds(binds); + } + + @Override + public String[] getCommandParts() { + return this.containerDef.getCommand(); + } + + @Override + public void setCommandParts(String[] commandParts) { + this.containerDef.setCommand(commandParts); + } + + public List getNetworkAliases() { + return new ArrayList<>(this.containerDef.getNetworkAliases()); + } + + public void setNetworkAliases(List aliases) { + this.containerDef.setNetworkAliases(new HashSet<>(aliases)); + } + + @Override + public List getPortBindings() { + return this.containerDef.portBindings.stream() + .map(it -> String.format("%s:%s", it.getBinding(), it.getExposedPort())) + .collect(Collectors.toList()); + } + + @Override + public void setPortBindings(List portBindings) { + this.containerDef.setPortBindings(portBindings.stream().map(PortBinding::parse).collect(Collectors.toSet())); + } + + public void setPrivilegedMode(boolean mode) { + this.containerDef.setPrivilegedMode(mode); + } + + public boolean isPrivilegedMode() { + return this.containerDef.isPrivilegedMode(); + } + + public Map getLabels() { + return this.containerDef.labels; + } + + public void setLabels(Map labels) { + this.containerDef.setLabels(labels); + } + + public String getNetworkMode() { + return this.containerDef.getNetworkMode(); + } + + public void setNetworkMode(String networkMode) { + this.containerDef.setNetworkMode(networkMode); + } + + 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 index 0df0480a1bf..6574a517cdf 100644 --- a/core/src/main/java/org/testcontainers/containers/InternetProtocol.java +++ b/core/src/main/java/org/testcontainers/containers/InternetProtocol.java @@ -4,7 +4,6 @@ * The IP protocols supported by Docker. */ public enum InternetProtocol { - TCP, UDP; 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 index 2fda5ad1a8d..27cab80c652 100644 --- a/core/src/main/java/org/testcontainers/containers/Network.java +++ b/core/src/main/java/org/testcontainers/containers/Network.java @@ -4,8 +4,6 @@ import lombok.Builder; import lombok.Getter; import lombok.Singular; -import org.junit.rules.ExternalResource; -import org.junit.rules.TestRule; import org.testcontainers.DockerClientFactory; import org.testcontainers.utility.ResourceReaper; @@ -17,8 +15,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; -public interface Network extends AutoCloseable, TestRule { - +public interface Network extends AutoCloseable { Network SHARED = new NetworkImpl(false, null, Collections.emptySet(), null) { @Override public void close() { @@ -41,7 +38,7 @@ static NetworkImpl.NetworkImplBuilder builder() { @Builder @Getter - class NetworkImpl extends ExternalResource implements Network { + class NetworkImpl implements Network { private final String name = UUID.randomUUID().toString(); @@ -60,9 +57,16 @@ class NetworkImpl extends ExternalResource implements Network { @Override public synchronized String getId() { if (initialized.compareAndSet(false, true)) { - id = create(); + boolean success = false; + try { + id = create(); + success = true; + } finally { + if (!success) { + initialized.set(false); + } + } } - return id; } @@ -87,16 +91,13 @@ private String create() { 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 - protected void after() { - close(); - } - @Override public synchronized void close() { if (initialized.getAndSet(false)) { diff --git a/core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java b/core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java index 8f88724647f..2d3104af6b1 100644 --- a/core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java +++ b/core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java @@ -1,18 +1,26 @@ 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.HashSet; +import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -25,14 +33,30 @@ class ParsedDockerComposeFile { private final Map composeFileContent; + private final String composeFileName; + private final File composeFile; @Getter - private Set dependencyImageNames = new HashSet<>(); + private final Map> serviceNameToImageNames = new HashMap<>(); ParsedDockerComposeFile(File composeFile) { - Yaml yaml = new Yaml(); + // 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) { @@ -54,16 +78,22 @@ class ParsedDockerComposeFile { private void parseAndValidate() { final Map servicesMap; - if (composeFileContent.containsKey("version")) { - if ("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("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); + log.debug( + "Compose file {} has an unknown format: 'version' is set but 'services' is not defined", + composeFileName + ); return; } if (!(servicesElement instanceof Map)) { @@ -71,7 +101,9 @@ private void parseAndValidate() { return; } - servicesMap = (Map) servicesElement; + @SuppressWarnings("unchecked") + Map temp = (Map) servicesElement; + servicesMap = temp; } else { servicesMap = composeFileContent; } @@ -80,66 +112,83 @@ private void parseAndValidate() { 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); + log.debug( + "Compose file {} has an unknown format: service '{}' is not Map", + composeFileName, + serviceName + ); break; } - final Map serviceDefinitionMap = (Map) serviceDefinition; + @SuppressWarnings("unchecked") + final Map serviceDefinitionMap = (Map) serviceDefinition; validateNoContainerNameSpecified(serviceName, serviceDefinitionMap); - findServiceImageName(serviceDefinitionMap); - findImageNamesInDockerfile(serviceDefinitionMap); + findServiceImageName(serviceName, serviceDefinitionMap); + findImageNamesInDockerfile(serviceName, serviceDefinitionMap); } } - private void validateNoContainerNameSpecified(String serviceName, Map 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 - )); + 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(Map serviceDefinitionMap) { - if (serviceDefinitionMap.containsKey("image") && serviceDefinitionMap.get("image") instanceof String) { - final String imageName = (String) serviceDefinitionMap.get("image"); + 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); - dependencyImageNames.add(imageName); + serviceNameToImageNames.put(serviceName, Sets.newHashSet(imageName)); } } - private void findImageNamesInDockerfile(Map serviceDefinitionMap) { + 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 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 + dockerfilePath = + composeFile + .getAbsoluteFile() + .getParentFile() + .toPath() + .resolve((String) contextRelativePath) + .resolve((String) dockerfileRelativePath) + .normalize(); + } + } else if (buildNode instanceof String) { + dockerfilePath = + composeFile + .getAbsoluteFile() .getParentFile() .toPath() - .resolve((String) contextRelativePath) - .resolve((String) dockerfileRelativePath) + .resolve((String) buildNode) + .resolve("./Dockerfile") .normalize(); - } - } else if (buildNode instanceof String) { - dockerfilePath = composeFile - .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.dependencyImageNames.addAll(resolvedImageNames); + 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 index b3f020500a1..dd4b6e94c97 100644 --- a/core/src/main/java/org/testcontainers/containers/PortForwardingContainer.java +++ b/core/src/main/java/org/testcontainers/containers/PortForwardingContainer.java @@ -15,10 +15,21 @@ 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<>()); @@ -28,16 +39,7 @@ public enum PortForwardingContainer { @SneakyThrows private Connection createSSHSession() { - String password = UUID.randomUUID().toString(); - container = new GenericContainer<>(DockerImageName.parse("testcontainers/sshd:1.0.0")) - .withExposedPorts(22) - .withEnv("PASSWORD", password) - .withCommand( - "sh", - "-c", - // Disable ipv6 & Make it listen on all interfaces, not just localhost - "echo \"root:$PASSWORD\" | chpasswd && /usr/sbin/sshd -D -o PermitRootLogin=yes -o AddressFamily=inet -o GatewayPorts=yes" - ); + container = new GenericContainer<>(DEFINITION); container.start(); Connection connection = new Connection(container.getHost(), container.getMappedPort(22)); @@ -49,7 +51,7 @@ private Connection createSSHSession() { (int) Duration.ofSeconds(30).toMillis() ); - if (!connection.authenticateWithPassword("root", password)) { + if (!connection.authenticateWithPassword("root", PASSWORD)) { throw new IllegalStateException("Authentication failed."); } @@ -63,14 +65,30 @@ public void exposeHostPort(int port) { @SneakyThrows public void exposeHostPort(int hostPort, int containerPort) { - if (exposedPorts.add(new AbstractMap.SimpleEntry<>(hostPort, containerPort))) { + if (exposedPorts.add(new AbstractMap.SimpleEntry<>(hostPort, containerPort))) { getSshConnection().requestRemotePortForwarding("", containerPort, "localhost", hostPort); } } + void start() { + getSshConnection(); + } + Optional getNetwork() { - return Optional.ofNullable(container) + 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 index 735d3e3a277..ec814d5640a 100644 --- a/core/src/main/java/org/testcontainers/containers/SelinuxContext.java +++ b/core/src/main/java/org/testcontainers/containers/SelinuxContext.java @@ -13,5 +13,4 @@ public enum SelinuxContext { 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 index 7592d9f4d1f..9455ac77227 100644 --- a/core/src/main/java/org/testcontainers/containers/SocatContainer.java +++ b/core/src/main/java/org/testcontainers/containers/SocatContainer.java @@ -1,10 +1,11 @@ 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; -import org.testcontainers.utility.Base58; -import org.testcontainers.utility.DockerImageName; /** * A socat container is used as a TCP proxy, enabling any TCP port of another container to be exposed @@ -15,7 +16,7 @@ public class SocatContainer extends GenericContainer { private final Map targets = new HashMap<>(); public SocatContainer() { - this(DockerImageName.parse("alpine/socat:1.7.3.4-r0")); + this(DockerImageName.parse("alpine/socat:1.7.4.3-r0")); } public SocatContainer(final DockerImageName dockerImageName) { @@ -36,10 +37,13 @@ public SocatContainer withTarget(int exposedPort, String host, int internalPort) @Override protected void configure() { - withCommand("-c", - targets.entrySet().stream() - .map(entry -> "socat TCP-LISTEN:" + entry.getKey() + ",fork,reuseaddr TCP:" + entry.getValue()) - .collect(Collectors.joining(" & ")) + 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 index be8a4ed735f..9e5f6957cdc 100644 --- a/core/src/main/java/org/testcontainers/containers/VncRecordingContainer.java +++ b/core/src/main/java/org/testcontainers/containers/VncRecordingContainer.java @@ -15,10 +15,9 @@ import java.nio.file.Files; import java.nio.file.StandardCopyOption; import java.time.Duration; +import java.time.temporal.ChronoUnit; import java.util.Base64; -import static java.time.temporal.ChronoUnit.SECONDS; - /** * 'Sidekick container' with the sole purpose of recording the VNC screen output from another container. * @@ -48,7 +47,9 @@ public class VncRecordingContainer extends GenericContainer targetContainer) { this( targetContainer.getNetwork(), - targetContainer.getNetworkAliases().stream() + targetContainer + .getNetworkAliases() + .stream() .findFirst() .orElseThrow(() -> new IllegalStateException("Target container must have a network alias")) ); @@ -57,14 +58,16 @@ public VncRecordingContainer(@NonNull GenericContainer targetContainer) { /** * 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.2.0")); - + 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, SECONDS))); + waitingFor( + new LogMessageWaitStrategy() + .withRegEx(".*Connected.*") + .withStartupTimeout(Duration.of(15, ChronoUnit.SECONDS)) + ); } public VncRecordingContainer withVncPassword(@NonNull String vncPassword) { @@ -95,8 +98,17 @@ protected void configure() { 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 + "echo '" + + encodedPassword + + "' | base64 -d > /vnc_password && " + + "flvrec.py -o " + + ORIGINAL_RECORDING_FILE_NAME + + " -d -r " + + frameRate + + " -P /vnc_password " + + targetNetworkAlias + + " " + + vncPort ); } @@ -122,7 +134,8 @@ public void saveRecordingToFile(@NonNull File file) { public enum VncRecordingFormat { FLV("flv") { @Override - String reencodeRecording(@NonNull VncRecordingContainer container, @NonNull String source) throws IOException, InterruptedException { + 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; @@ -130,17 +143,29 @@ String reencodeRecording(@NonNull VncRecordingContainer container, @NonNull Stri }, MP4("mp4") { @Override - String reencodeRecording(@NonNull VncRecordingContainer container, @NonNull String source) throws IOException, InterruptedException { + 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); + 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; + 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/output/BaseConsumer.java b/core/src/main/java/org/testcontainers/containers/output/BaseConsumer.java index 2967eb4d33a..7679ee8ab57 100644 --- a/core/src/main/java/org/testcontainers/containers/output/BaseConsumer.java +++ b/core/src/main/java/org/testcontainers/containers/output/BaseConsumer.java @@ -6,6 +6,7 @@ import java.util.function.Consumer; public abstract class BaseConsumer> implements Consumer { + @Getter @Setter private boolean removeColorCodes = true; 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 8c7480320a8..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,18 +1,14 @@ package org.testcontainers.containers.output; - import com.github.dockerjava.api.async.ResultCallbackTemplate; import com.github.dockerjava.api.model.Frame; -import com.github.dockerjava.api.model.StreamType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; +import java.nio.charset.StandardCharsets; import java.util.HashMap; -import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.function.Consumer; @@ -25,25 +21,9 @@ public class FrameConsumerResultCallback extends ResultCallbackTemplate> consumers; - - private CountDownLatch completionLatch = new CountDownLatch(1); - - private StringBuilder logString = new StringBuilder(); - - private OutputFrame brokenFrame; + 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. @@ -52,23 +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 {}, for which no handler is configured", frame.getStreamType()); - } else if (outputFrame.getBytes() != null && outputFrame.getBytes().length > 0) { - if (frame.getStreamType() == StreamType.RAW) { - processRawFrame(outputFrame, consumer); - } else { - processOtherFrame(outputFrame, consumer); - } + } else if (frame.getPayload() != null) { + consumer.processFrame(frame.getPayload()); } } } @@ -79,24 +55,17 @@ public void onError(Throwable throwable) { // Sink any errors try { close(); - } catch (IOException ignored) { } + } catch (IOException ignored) {} } @Override public void close() throws IOException { - OutputFrame lastLine = null; - - if (logString.length() > 0) { - lastLine = new OutputFrame(OutputFrame.OutputType.STDOUT, logString.toString().getBytes()); + if (completionLatch.getCount() == 0) { + return; } - // send an END frame to every consumer... but only once per consumer. - for (Consumer consumer : new HashSet<>(consumers.values())) { - if (lastLine != null) { - consumer.accept(lastLine); - } - consumer.accept(OutputFrame.END); - } + consumers.values().forEach(LineConsumer::processBuffer); + consumers.values().forEach(LineConsumer::end); super.close(); completionLatch.countDown(); @@ -109,71 +78,75 @@ public CountDownLatch getCompletionLatch() { return completionLatch; } - private synchronized void processRawFrame(OutputFrame outputFrame, Consumer consumer) { - String utf8String = outputFrame.getUtf8String(); - byte[] bytes = outputFrame.getBytes(); + private static class LineConsumer { - // Merging the strings by bytes to solve the problem breaking non-latin unicode symbols. - if (brokenFrame != null) { - bytes = merge(brokenFrame.getBytes(), bytes); - utf8String = new String(bytes); - brokenFrame = null; - } - // Logger chunks can break the string in middle of multibyte unicode character. - // Backup the bytes to reconstruct proper char sequence with bytes from next frame. - int lastCharacterType = Character.getType(utf8String.charAt(utf8String.length() - 1)); - if (lastCharacterType == Character.OTHER_SYMBOL) { - brokenFrame = new OutputFrame(outputFrame.getType(), bytes); - return; - } + private static final Pattern ANSI_COLOR_PATTERN = Pattern.compile("\u001B\\[[0-9;]+m"); - utf8String = processAnsiColorCodes(utf8String, consumer); - normalizeLogLines(utf8String, consumer); - } + private final OutputFrame.OutputType type; - private synchronized void processOtherFrame(OutputFrame outputFrame, Consumer consumer) { - String utf8String = outputFrame.getUtf8String(); + private final Consumer consumer; - utf8String = processAnsiColorCodes(utf8String, consumer); - consumer.accept(new OutputFrame(outputFrame.getType(), utf8String.getBytes())); - } + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - private void normalizeLogLines(String utf8String, Consumer consumer) { - // Reformat strings to normalize new lines. - List lines = new ArrayList<>(Arrays.asList(utf8String.split(LINE_BREAK_REGEX))); - if (lines.isEmpty()) { - consumer.accept(new OutputFrame(OutputFrame.OutputType.STDOUT, EMPTY_LINE)); - return; - } - if (utf8String.startsWith("\n") || utf8String.startsWith("\r")) { - lines.add(0, ""); + private boolean lastCR = false; + + LineConsumer(final OutputFrame.OutputType type, final Consumer consumer) { + this.type = type; + this.consumer = consumer; } - if (utf8String.endsWith("\n") || utf8String.endsWith("\r")) { - lines.add(""); + + 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); } - for (int i = 0; i < lines.size() - 1; i++) { - String line = lines.get(i); - if (i == 0 && logString.length() > 0) { - line = logString.toString() + line; - logString.setLength(0); + + void processBuffer() { + if (buffer.size() > 0) { + consume(); } - consumer.accept(new OutputFrame(OutputFrame.OutputType.STDOUT, line.getBytes())); } - logString.append(lines.get(lines.size() - 1)); - } - private String processAnsiColorCodes(String utf8String, Consumer consumer) { - if (!(consumer instanceof BaseConsumer) || ((BaseConsumer) consumer).isRemoveColorCodes()) { - return ANSI_COLOR_PATTERN.matcher(utf8String).replaceAll(""); + void end() { + consumer.accept(OutputFrame.END); } - return utf8String; - } + 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 byte[] merge(byte[] str1, byte[] str2) { - byte[] mergedString = new byte[str1.length + str2.length]; - System.arraycopy(str1, 0, mergedString, 0, str1.length); - System.arraycopy(str2, 0, mergedString, str1.length, str2.length); - return mergedString; + 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 d5ddcada8c8..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 ""; } + 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 a274cd63004..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,18 +1,22 @@ package org.testcontainers.containers.output; -import java.util.HashMap; -import java.util.Map; - import org.slf4j.Logger; import org.slf4j.MDC; +import java.util.HashMap; +import java.util.Map; + /** * A consumer for container output that logs output to an SLF4J logger. */ public class Slf4jLogConsumer extends BaseConsumer { + private final Logger logger; + private final Map mdc = new HashMap<>(); + private boolean separateOutputStreams; + private String prefix = ""; public Slf4jLogConsumer(Logger logger) { @@ -46,12 +50,10 @@ public Slf4jLogConsumer withSeparateOutputStreams() { @Override public void accept(OutputFrame outputFrame) { - OutputFrame.OutputType outputType = outputFrame.getType(); - - String utf8String = outputFrame.getUtf8String(); - utf8String = utf8String.replaceAll(FrameConsumerResultCallback.LINE_BREAK_AT_END_REGEX, ""); + final OutputFrame.OutputType outputType = outputFrame.getType(); + final String utf8String = outputFrame.getUtf8StringWithoutLineEnding(); - Map originalMdc = MDC.getCopyOfContextMap(); + final Map originalMdc = MDC.getCopyOfContextMap(); MDC.setContextMap(mdc); try { switch (outputType) { 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 40ec60fb744..490e8d70cba 100644 --- a/core/src/main/java/org/testcontainers/containers/output/ToStringConsumer.java +++ b/core/src/main/java/org/testcontainers/containers/output/ToStringConsumer.java @@ -10,14 +10,15 @@ * Created by rnorth on 26/03/2016. */ 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 858f5a4b487..e79c234058e 100644 --- a/core/src/main/java/org/testcontainers/containers/output/WaitingConsumer.java +++ b/core/src/main/java/org/testcontainers/containers/output/WaitingConsumer.java @@ -42,7 +42,7 @@ 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 + // ~2.9 thousands centuries ought to be enough for anyone waitUntil(predicate, Long.MAX_VALUE, 1); } @@ -71,22 +71,25 @@ public void waitUntil(Predicate predicate, int limit, TimeUnit limi * @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 expiry = limitUnit.toMillis(limit) + System.currentTimeMillis(); + public void waitUntil(Predicate predicate, long limit, TimeUnit limitUnit, int times) + throws TimeoutException { + long timeoutLimitInNanos = limitUnit.toNanos(limit); - waitUntil(predicate, expiry, times); + waitUntil(predicate, timeoutLimitInNanos, times); } - private void waitUntil(Predicate predicate, long expiry, int times) throws TimeoutException { - + private void waitUntil(Predicate predicate, long timeoutLimitInNanos, int times) + throws TimeoutException { int numberOfMatches = 0; - while (System.currentTimeMillis() < expiry) { + + 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) { - final String trimmedFrameText = frame.getUtf8String().replaceFirst("\n$", ""); - LOGGER.debug("{}: {}", frame.getType(), trimmedFrameText); + LOGGER.debug("{}: {}", frame.getType(), frame.getUtf8StringWithoutLineEnding()); if (predicate.test(frame)) { numberOfMatches++; @@ -129,13 +132,13 @@ public void waitUntilEnd() { * @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 1425123473b..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,24 +5,23 @@ 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(CONTAINER_RUNNING_TIMEOUT_SEC); + private Duration timeout = Duration.ofSeconds(GenericContainer.CONTAINER_RUNNING_TIMEOUT_SEC); @SuppressWarnings("unchecked") public SELF withTimeout(Duration timeout) { @@ -30,21 +29,37 @@ public SELF withTimeout(Duration 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((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; - } - }); - }); + 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]; } @@ -55,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 76e71378969..4973c4dc9c1 100644 --- a/core/src/main/java/org/testcontainers/containers/traits/LinkableContainer.java +++ b/core/src/main/java/org/testcontainers/containers/traits/LinkableContainer.java @@ -7,6 +7,5 @@ */ @Deprecated public interface LinkableContainer { - String getContainerName(); } 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 index b9b63eeee5c..fbee9c2f9f4 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheck.java +++ b/core/src/main/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheck.java @@ -4,6 +4,7 @@ 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; @@ -13,20 +14,25 @@ */ @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 { - new Socket(address, externalPort).close(); - } catch (IOException e) { - throw new IllegalStateException("Socket not listening yet: " + externalPort); - } - }); + 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 index cd601e6a131..aeedbea1e3f 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/internal/InternalCommandPortListeningCheck.java +++ b/core/src/main/java/org/testcontainers/containers/wait/internal/InternalCommandPortListeningCheck.java @@ -10,8 +10,6 @@ import java.time.Instant; import java.util.Set; -import static java.lang.String.format; - /** * Mechanism for testing that a socket is listening when run from the container being checked. */ @@ -20,28 +18,46 @@ public class InternalCommandPortListeningCheck implements java.util.concurrent.Callable { private final WaitStrategyTarget waitStrategyTarget; + private final Set internalPorts; @Override public Boolean call() { - StringBuilder command = new StringBuilder("true"); + StringBuilder command = new StringBuilder("while true; do ( true "); for (int internalPort : internalPorts) { command.append(" && "); command.append(" ("); - command.append(format("cat /proc/net/tcp* | awk '{print $2}' | grep -i ':0*%x'", internalPort)); + command.append(String.format("grep -i ':0*%x' /proc/net/tcp*", internalPort)); command.append(" || "); - command.append(format("nc -vz -w 1 localhost %d", internalPort)); + command.append(String.format("nc -vz -w 1 localhost %d", internalPort)); command.append(" || "); - command.append(format("/bin/bash -c ' externalLivenessCheckPorts = getLivenessCheckPorts(); - if (externalLivenessCheckPorts.isEmpty()) { - if (log.isDebugEnabled()) { - log.debug("Liveness check ports of {} is empty. Not waiting.", waitStrategyTarget.getContainerInfo().getName()); + 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; } - return; + } else { + externalLivenessCheckPorts = + Arrays + .stream(this.ports) + .mapToObj(port -> waitStrategyTarget.getMappedPort(port)) + .collect(Collectors.toSet()); } - @SuppressWarnings("unchecked") List exposedPorts = waitStrategyTarget.getExposedPorts(); final Set internalPorts = getInternalPorts(externalLivenessCheckPorts, exposedPorts); Callable internalCheck = new InternalCommandPortListeningCheck(waitStrategyTarget, internalPorts); - Callable externalCheck = new ExternalPortListeningCheck(waitStrategyTarget, externalLivenessCheckPorts); + Callable externalCheck = new ExternalPortListeningCheck( + waitStrategyTarget, + externalLivenessCheckPorts + ); try { - Unreliables.retryUntilTrue((int) startupTimeout.getSeconds(), TimeUnit.SECONDS, - () -> getRateLimiter().getWhenReady(() -> internalCheck.call() && externalCheck.call())); - - } catch (TimeoutException e) { - throw new ContainerLaunchException("Timed out waiting for container port to open (" + - waitStrategyTarget.getHost() + - " ports: " + - externalLivenessCheckPorts + - " should be listening)"); + 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()); + 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 index c4751725707..59f6d9077fb 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/strategy/HttpWaitStrategy.java +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/HttpWaitStrategy.java @@ -2,8 +2,6 @@ import com.google.common.base.Strings; import com.google.common.io.BaseEncoding; -import java.util.HashMap; -import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.rnorth.ducttape.TimeoutException; import org.testcontainers.containers.ContainerLaunchException; @@ -12,15 +10,30 @@ 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 @@ -37,17 +50,29 @@ public class HttpWaitStrategy extends AbstractWaitStrategy { 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. * @@ -112,6 +137,16 @@ public HttpWaitStrategy withMethod(String 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. * @@ -174,14 +209,16 @@ public HttpWaitStrategy forResponsePredicate(Predicate responsePredicate 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(); - }); + 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; @@ -191,11 +228,20 @@ protected void waitUntilReady() { try { // Un-map the port for logging - int originalPort = waitStrategyTarget.getExposedPorts().stream() + 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); + 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); @@ -203,67 +249,130 @@ protected void waitUntilReady() { // try to connect to the URL try { - retryUntilSuccess((int) startupTimeout.getSeconds(), TimeUnit.SECONDS, () -> { - getRateLimiter().doWhenReady(() -> { - try { - final HttpURLConnection connection = (HttpURLConnection) new URL(uri).openConnection(); - connection.setReadTimeout(Math.toIntExact(readTimeout.toMillis())); - - // authenticate - if (!Strings.isNullOrEmpty(username)) { - connection.setRequestProperty(HEADER_AUTHORIZATION, buildAuthString(username, password)); - connection.setUseCaches(false); - } + 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 + ); + } + } - // 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())); + 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]; } - if(responsePredicate != null) { - String responseBody = getResponseBody(connection); + @Override + public void checkClientTrusted(final X509Certificate[] certs, final String authType) {} - log.trace("Get response {}", responseBody); + @Override + public void checkServerTrusted(final X509Certificate[] certs, final String authType) {} - if(!responsePredicate.test(responseBody)) { - throw new RuntimeException(String.format("Response: %s did not match predicate", - responseBody)); - } - } + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) {} - } catch (IOException e) { - throw new RuntimeException(e); - } - }); - return true; - }); + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) {} - } 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)); + @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(); } } 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 index 9f7a0078f00..7a095d888f2 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/strategy/LogMessageWaitStrategy.java +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/LogMessageWaitStrategy.java @@ -2,7 +2,6 @@ import com.github.dockerjava.api.command.LogContainerCmd; import lombok.SneakyThrows; -import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.ContainerLaunchException; import org.testcontainers.containers.output.FrameConsumerResultCallback; import org.testcontainers.containers.output.OutputFrame; @@ -13,9 +12,6 @@ import java.util.concurrent.TimeoutException; import java.util.function.Predicate; -import static org.testcontainers.containers.output.OutputFrame.OutputType.STDERR; -import static org.testcontainers.containers.output.OutputFrame.OutputType.STDOUT; - public class LogMessageWaitStrategy extends AbstractWaitStrategy { private String regEx; @@ -27,22 +23,24 @@ public class LogMessageWaitStrategy extends AbstractWaitStrategy { protected void waitUntilReady() { WaitingConsumer waitingConsumer = new WaitingConsumer(); - LogContainerCmd cmd = DockerClientFactory.instance().client().logContainerCmd(waitStrategyTarget.getContainerId()) + LogContainerCmd cmd = waitStrategyTarget + .getDockerClient() + .logContainerCmd(waitStrategyTarget.getContainerId()) .withFollowStream(true) .withSince(0) .withStdOut(true) .withStdErr(true); try (FrameConsumerResultCallback callback = new FrameConsumerResultCallback()) { - callback.addConsumer(STDOUT, waitingConsumer); - callback.addConsumer(STDERR, waitingConsumer); + callback.addConsumer(OutputFrame.OutputType.STDOUT, waitingConsumer); + callback.addConsumer(OutputFrame.OutputType.STDERR, waitingConsumer); cmd.exec(callback); - Predicate waitPredicate = outputFrame -> + Predicate waitPredicate = outputFrame -> { // (?s) enables line terminator matching (equivalent to Pattern.DOTALL) - outputFrame.getUtf8String().matches("(?s)" + regEx); - + return outputFrame.getUtf8String().matches("(?s)" + regEx); + }; try { waitingConsumer.waitUntil(waitPredicate, startupTimeout.getSeconds(), TimeUnit.SECONDS, times); } catch (TimeoutException e) { 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 index 034a83a2949..5a70c7f2fbc 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/strategy/Wait.java +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/Wait.java @@ -1,12 +1,11 @@ package org.testcontainers.containers.wait.strategy; -import java.net.HttpURLConnection; - /** * Convenience class with logic for building common {@link WaitStrategy} instances. * */ public class Wait { + /** * Convenience method to return the default WaitStrategy. * @@ -26,6 +25,16 @@ 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. * @@ -34,8 +43,7 @@ public static HostPortWaitStrategy forListeningPort() { * @see HttpWaitStrategy */ public static HttpWaitStrategy forHttp(String path) { - return new HttpWaitStrategy() - .forPath(path); + return new HttpWaitStrategy().forPath(path); } /** @@ -46,8 +54,7 @@ public static HttpWaitStrategy forHttp(String path) { * @see HttpWaitStrategy */ public static HttpWaitStrategy forHttps(String path) { - return forHttp(path) - .usingTls(); + return forHttp(path).usingTls(); } /** @@ -69,4 +76,14 @@ public static LogMessageWaitStrategy forLogMessage(String regex, int times) { 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 index cebacb375b6..5c7530fc67f 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitAllStrategy.java +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitAllStrategy.java @@ -1,16 +1,15 @@ 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; -import org.rnorth.ducttape.timeouts.Timeouts; - 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 @@ -30,11 +29,13 @@ public enum Mode { * 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 + WITH_MAXIMUM_OUTER_TIMEOUT, } private final Mode mode; + private final List strategies = new ArrayList<>(); + private Duration timeout = Duration.ofSeconds(30); public WaitAllStrategy() { @@ -50,9 +51,13 @@ public void waitUntilReady(WaitStrategyTarget waitStrategyTarget) { if (mode == Mode.WITH_INDIVIDUAL_TIMEOUTS_ONLY) { waitUntilNestedStrategiesAreReady(waitStrategyTarget); } else { - Timeouts.doWithTimeout((int) timeout.toMillis(), TimeUnit.MILLISECONDS, () -> { - waitUntilNestedStrategiesAreReady(waitStrategyTarget); - }); + Timeouts.doWithTimeout( + (int) timeout.toMillis(), + TimeUnit.MILLISECONDS, + () -> { + waitUntilNestedStrategiesAreReady(waitStrategyTarget); + } + ); } } @@ -63,7 +68,6 @@ private void waitUntilNestedStrategiesAreReady(WaitStrategyTarget waitStrategyTa } public WaitAllStrategy withStrategy(WaitStrategy strategy) { - if (mode == Mode.WITH_OUTER_TIMEOUT) { applyStartupTimeout(strategy); } @@ -74,10 +78,13 @@ public WaitAllStrategy withStrategy(WaitStrategy strategy) { @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)); + throw new IllegalStateException( + String.format( + "Changing startup timeout is not supported with mode %s", + Mode.WITH_INDIVIDUAL_TIMEOUTS_ONLY + ) + ); } this.timeout = startupTimeout; 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 index 25703174502..ff371cf775f 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategy.java +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategy.java @@ -3,7 +3,6 @@ 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 index 49f88fb7711..778c252009b 100644 --- a/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategyTarget.java +++ b/core/src/main/java/org/testcontainers/containers/wait/strategy/WaitStrategyTarget.java @@ -6,13 +6,15 @@ 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()); + 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 index 2dc88cad313..3f6c5c3e1c8 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/AuditLoggingDockerClient.java +++ b/core/src/main/java/org/testcontainers/dockerclient/AuditLoggingDockerClient.java @@ -1,17 +1,23 @@ package org.testcontainers.dockerclient; import com.github.dockerjava.api.DockerClient; -import com.github.dockerjava.api.command.*; +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; -import static org.testcontainers.utility.AuditLogger.doLog; - /** * Wrapper for {@link DockerClient} to facilitate 'audit logging' of potentially destruction actions using * {@link org.testcontainers.utility.AuditLogger}. @@ -30,97 +36,117 @@ public AuditLoggingDockerClient(DockerClient wrappedClient) { @Override public CreateContainerCmd createContainerCmd(@NotNull String image) { - return wrappedCommand(CreateContainerCmd.class, - wrappedClient.createContainerCmd(image), - (cmd, res) -> doLog("CREATE", image, res.getId(), cmd), - (cmd, e) -> doLog("CREATE", image, null, cmd, e)); - + 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) -> doLog("START", null, containerId, cmd), - (cmd, e) -> doLog("START", null, containerId, cmd, e)); + 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) -> doLog("REMOVE", null, containerId, cmd), - (cmd, e) -> doLog("REMOVE", null, containerId, cmd, e)); + 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) -> doLog("STOP", null, containerId, cmd), - (cmd, e) -> doLog("STOP", null, containerId, cmd, e)); + 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) -> doLog("KILL", null, containerId, cmd), - (cmd, e) -> doLog("KILL", null, containerId, cmd, e)); + 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) -> doLog("CREATE_NETWORK", null, null, cmd), - (cmd, e) -> doLog("CREATE_NETWORK", null, null, cmd, e)); + 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) -> doLog("REMOVE_NETWORK", null, null, cmd), - (cmd, e) -> doLog("REMOVE_NETWORK", null, null, cmd, e)); + 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) { - + 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; + 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(); } - } else { - return method.invoke(cmd, args); + 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 index 5acb7ab8835..5713d054e13 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/AuthDelegatingDockerClientConfig.java +++ b/core/src/main/java/org/testcontainers/dockerclient/AuthDelegatingDockerClientConfig.java @@ -2,13 +2,12 @@ import com.github.dockerjava.api.model.AuthConfig; import com.github.dockerjava.core.DockerClientConfig; -import lombok.experimental.Delegate; +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; -import static org.testcontainers.utility.AuthConfigUtil.toSafeString; - /** * Facade implementation for {@link DockerClientConfig} which overrides how authentication * configuration is obtained. A delegate {@link DockerClientConfig} will be called first @@ -18,37 +17,34 @@ * TODO move to docker-java */ @Slf4j -class AuthDelegatingDockerClientConfig implements DockerClientConfig { - - @Delegate(excludes = DelegateExclusions.class) - private DockerClientConfig delegate; +class AuthDelegatingDockerClientConfig extends DockerClientConfigDelegate { public AuthDelegatingDockerClientConfig(DockerClientConfig delegate) { - this.delegate = delegate; + super(delegate); } + @Override public AuthConfig effectiveAuthConfig(String imageName) { // allow docker-java auth config to be used as a fallback AuthConfig fallbackAuthConfig; try { - fallbackAuthConfig = delegate.effectiveAuthConfig(imageName); + fallbackAuthConfig = super.effectiveAuthConfig(imageName); } catch (Exception e) { - log.debug("Delegate call to effectiveAuthConfig failed with cause: '{}'. " + + log.debug( + "Delegate call to effectiveAuthConfig failed with cause: '{}'. " + "Resolution of auth config will continue using RegistryAuthLocator.", - e.getMessage()); + 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() + final AuthConfig effectiveAuthConfig = RegistryAuthLocator + .instance() .lookupAuthConfig(parsed, fallbackAuthConfig); - log.debug("Effective auth config [{}]", toSafeString(effectiveAuthConfig)); + log.debug("Effective auth config [{}]", AuthConfigUtil.toSafeString(effectiveAuthConfig)); return effectiveAuthConfig; } - - private interface DelegateExclusions { - AuthConfig effectiveAuthConfig(String imageName); - } } diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerClientConfigUtils.java b/core/src/main/java/org/testcontainers/dockerclient/DockerClientConfigUtils.java index b494eb087f6..7206a7e1452 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/DockerClientConfigUtils.java +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerClientConfigUtils.java @@ -2,14 +2,13 @@ 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 static java.util.concurrent.TimeUnit.SECONDS; +import java.util.concurrent.TimeUnit; @Slf4j public class DockerClientConfigUtils { @@ -19,25 +18,31 @@ public class DockerClientConfigUtils { @Getter(lazy = true) private static final Optional defaultGateway = Optional - .ofNullable(DockerClientFactory.instance().runInsideDocker( + .ofNullable( + DockerClientFactory + .instance() + .runInsideDocker( cmd -> cmd.withCmd("sh", "-c", "ip route|awk '/default/ { print $3 }'"), (client, id) -> { try { LogToStringContainerCallback loggingCallback = new LogToStringContainerCallback(); - client.logContainerCmd(id).withStdOut(true) - .withFollowStream(true) - .exec(loggingCallback) - .awaitStarted(); - loggingCallback.awaitCompletion(3, SECONDS); + 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); /** * @deprecated use {@link DockerClientProviderStrategy#getDockerHostIpAddress()} diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java index 35e3b6eabb8..7b0aaafc169 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java @@ -6,33 +6,65 @@ import com.github.dockerjava.core.DefaultDockerClientConfig; import com.github.dockerjava.core.DockerClientImpl; import com.github.dockerjava.core.RemoteApiVersion; -import com.github.dockerjava.okhttp.OkDockerHttpClient; 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.lang.StringUtils; +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.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 { @@ -42,10 +74,14 @@ public abstract class DockerClientProviderStrategy { private String dockerHostIpAddress; - private final RateLimiter PING_RATE_LIMITER = RateLimiterBuilder.newBuilder() - .withRate(10, TimeUnit.SECONDS) - .withConstantThroughput() - .build(); + @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); @@ -62,6 +98,17 @@ protected boolean isPersistable() { return true; } + 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 */ @@ -83,14 +130,18 @@ protected int getPriority() { 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; - }); - }); + 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; @@ -98,122 +149,240 @@ public DockerClient getClient() { return dockerClient; } + /** + * 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. * * @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"); + 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<>(); - return Stream - .concat( - Stream - .of(TestcontainersConfiguration.getInstance().getDockerClientStrategyClassName()) - .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())), - strategies - .stream() - .filter(DockerClientProviderStrategy::isApplicable) - .sorted(Comparator.comparing(DockerClientProviderStrategy::getPriority).reversed()) - ) - .flatMap(strategy -> { - try { - DockerClient dockerClient = strategy.getDockerClient(); - - Info info; - try { - info = Unreliables.retryUntilSuccess(30, TimeUnit.SECONDS, () -> { - return strategy.PING_RATE_LIMITER.getWhenReady(() -> { - log.debug("Pinging docker daemon..."); - return dockerClient.infoCmd().exec(); - }); - }); - } catch (TimeoutException e) { - IOUtils.closeQuietly(dockerClient); - throw e; - } - 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 = info.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"); - } + // Manually enforce priority independent of priority property of strategy + allStrategies.add(new TestcontainersHostPropertyClientProviderStrategy()); + allStrategies.add(new EnvironmentAndSystemPropertyClientProviderStrategy()); - if (strategy.isPersistable()) { - TestcontainersConfiguration.getInstance().updateUserConfig("docker.client.strategy", strategy.getClass().getName()); - } + // Next strategy to try out is the one configured using the Testcontainers configuration mechanism + loadConfiguredStrategy().ifPresent(allStrategies::add); - return Stream.of(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); + // Finally, add all other strategies ordered by their internal priority + strategies + .stream() + .sorted(Comparator.comparing(DockerClientProviderStrategy::getPriority).reversed()) + .collect(Collectors.toCollection(() -> allStrategies)); - log.debug(failureDescription); - return Stream.empty(); - } - }) - .findAny() - .orElseThrow(() -> { - log.error("Could not find a valid Docker environment. Please check configuration. Attempted configurations were:"); - for (String failureMessage : configurationFailures) { - log.error(" " + failureMessage); - } - log.error("As no valid configuration was found, execution cannot continue"); + Predicate distinctStrategyClassPredicate = new Predicate() { + final Set> classes = new HashSet<>(); + + @Override + public boolean test(DockerClientProviderStrategy dockerClientProviderStrategy) { + return classes.add(dockerClientProviderStrategy.getClass()); + } + }; - FAIL_FAST_ALWAYS.set(true); - return new IllegalStateException("Could not find a valid Docker environment. Please see logs and check configuration"); - }); + 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." + ); + + FAIL_FAST_ALWAYS.set(true); + return new IllegalStateException( + "Could not find a valid Docker environment. Please see logs and check configuration" + ); + }); + } + + private static boolean tryOutStrategy(List configurationFailures, DockerClientProviderStrategy strategy) { + try { + log.debug("Trying out strategy: {}", strategy.getClass().getSimpleName()); + + if (!strategy.test()) { + log.debug("strategy {} did not pass the test", strategy.getClass().getSimpleName()); + return false; + } + + 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; + } + } + + 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) { @@ -221,49 +390,66 @@ public static DockerClient getClientForConfig(TransportConfig transportConfig) { String transportType = TestcontainersConfiguration.getInstance().getTransportType(); switch (transportType) { - case "okhttp": - dockerHttpClient = new OkDockerHttpClient.Builder() - .dockerHost(transportConfig.getDockerHost()) - .sslConfig(transportConfig.getSslConfig()) - .build(); - break; case "httpclient5": - dockerHttpClient = new ZerodepDockerHttpClient.Builder() - .dockerHost(transportConfig.getDockerHost()) - .sslConfig(transportConfig.getSslConfig()) - .build(); + dockerHttpClient = + new ZerodepDockerHttpClient.Builder() + .dockerHost(transportConfig.getDockerHost()) + .sslConfig(transportConfig.getSslConfig()) + .build(); break; default: throw new IllegalArgumentException("Unknown transport type '" + transportType + "'"); } - DefaultDockerClientConfig.Builder configBuilder = DefaultDockerClientConfig.createDefaultConfigBuilder(); + DefaultDockerClientConfig.Builder configBuilder = DefaultDockerClientConfig + .createDefaultConfigBuilder() + .withDockerHost(transportConfig.getDockerHost().toString()); + + Map headers = new HashMap<>(); + headers.put("x-tc-sid", DockerClientFactory.SESSION_ID); + headers.put("User-Agent", String.format("tc-java/%s", DockerClientFactory.TESTCONTAINERS_VERSION)); - if (configBuilder.build().getApiVersion() == RemoteApiVersion.UNKNOWN_VERSION) { - configBuilder.withApiVersion(RemoteApiVersion.VERSION_1_30); + 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) + ); } - return DockerClientImpl.getInstance( - new AuthDelegatingDockerClientConfig( - configBuilder - .withDockerHost(transportConfig.getDockerHost().toString()) - .build() - ), - dockerHttpClient - ); } public synchronized String getDockerHostIpAddress() { if (dockerHostIpAddress == null) { - dockerHostIpAddress = resolveDockerHostIpAddress(getDockerClient(), getTransportConfig().getDockerHost()); + dockerHostIpAddress = + resolveDockerHostIpAddress( + getDockerClient(), + getTransportConfig().getDockerHost(), + allowUserOverrides() + ); } return dockerHostIpAddress; } @VisibleForTesting - static String resolveDockerHostIpAddress(DockerClient client, URI dockerHost) { - String hostOverride = System.getenv("TESTCONTAINERS_HOST_OVERRIDE"); - if (!StringUtils.isBlank(hostOverride)) { - return hostOverride; + 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()) { @@ -274,7 +460,8 @@ static String resolveDockerHostIpAddress(DockerClient client, URI dockerHost) { case "unix": case "npipe": if (DockerClientConfigUtils.IN_A_CONTAINER) { - return client.inspectNetworkCmd() + return client + .inspectNetworkCmd() .withNetworkId("bridge") .exec() .getIpam() 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 4de508ea808..7c05a11c198 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/DockerMachineClientProviderStrategy.java +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerMachineClientProviderStrategy.java @@ -1,6 +1,7 @@ package org.testcontainers.dockerclient; 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; @@ -11,8 +12,6 @@ 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. * @@ -27,10 +26,16 @@ public final class DockerMachineClientProviderStrategy extends DockerClientProvi private TransportConfig resolveTransportConfig() throws InvalidConfigurationException { boolean installed = DockerMachineClient.instance().isInstalled(); - checkArgument(installed, "docker-machine executable was not found on PATH (" + Arrays.toString(CommandLine.getSystemPath()) + ")"); + 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"); + Preconditions.checkArgument( + machineNameOptional.isPresent(), + "docker-machine is installed but no default machine could be found" + ); String machineName = machineNameOptional.get(); log.info("Found docker-machine, and will use machine named {}", machineName); @@ -41,7 +46,8 @@ private TransportConfig resolveTransportConfig() throws InvalidConfigurationExce log.info("Docker daemon URL for docker machine {} is {}", machineName, dockerDaemonUrl); - return TransportConfig.builder() + return TransportConfig + .builder() .dockerHost(URI.create(dockerDaemonUrl)) .sslConfig( new LocalDirectorySSLConfig( @@ -55,7 +61,10 @@ private TransportConfig resolveTransportConfig() throws InvalidConfigurationExce protected boolean isApplicable() { boolean installed = DockerMachineClient.instance().isInstalled(); if (!installed) { - log.info("docker-machine executable was not found on PATH ({})", Arrays.toString(CommandLine.getSystemPath())); + log.info( + "docker-machine executable was not found on PATH ({})", + Arrays.toString(CommandLine.getSystemPath()) + ); return false; } diff --git a/core/src/main/java/org/testcontainers/dockerclient/EnvironmentAndSystemPropertyClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/EnvironmentAndSystemPropertyClientProviderStrategy.java index e0afba9af93..969610373d1 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/EnvironmentAndSystemPropertyClientProviderStrategy.java +++ b/core/src/main/java/org/testcontainers/dockerclient/EnvironmentAndSystemPropertyClientProviderStrategy.java @@ -2,10 +2,20 @@ 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 */ @@ -14,17 +24,47 @@ public final class EnvironmentAndSystemPropertyClientProviderStrategy extends Do public static final int PRIORITY = 100; - // Try using environment variables - private final DockerClientConfig dockerClientConfig = DefaultDockerClientConfig.createDefaultConfigBuilder().build(); + private final DockerClientConfig dockerClientConfig; - @Override - protected boolean isApplicable() { - return System.getenv("DOCKER_HOST") != null; + @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); + } + + dockerClientConfig = configBuilder.build(); + } + + private Optional getSetting(final String name) { + return Optional.ofNullable(TestcontainersConfiguration.getInstance().getEnvVarOrUserProperty(name, null)); } @Override public TransportConfig getTransportConfig() { - return TransportConfig.builder() + return TransportConfig + .builder() .dockerHost(dockerClientConfig.getDockerHost()) .sslConfig(dockerClientConfig.getSSLConfig()) .build(); @@ -37,6 +77,14 @@ protected int getPriority() { @Override public String getDescription() { - return "Environment variables, system properties and defaults. Resolved dockerHost=" + dockerClientConfig.getDockerHost(); + return ( + "Environment variables, system properties and defaults. Resolved dockerHost=" + + dockerClientConfig.getDockerHost() + ); + } + + @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 index ef834c9cec5..504378cb17e 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/InvalidConfigurationException.java +++ b/core/src/main/java/org/testcontainers/dockerclient/InvalidConfigurationException.java @@ -12,4 +12,4 @@ public InvalidConfigurationException(String s) { public InvalidConfigurationException(String message, Throwable cause) { super(message, cause); } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/testcontainers/dockerclient/LogToStringContainerCallback.java b/core/src/main/java/org/testcontainers/dockerclient/LogToStringContainerCallback.java index d376b685a91..2c0716b65f0 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/LogToStringContainerCallback.java +++ b/core/src/main/java/org/testcontainers/dockerclient/LogToStringContainerCallback.java @@ -9,6 +9,7 @@ */ @Deprecated public class LogToStringContainerCallback extends ResultCallback.Adapter { + private final StringBuffer log = new StringBuffer(); @Override diff --git a/core/src/main/java/org/testcontainers/dockerclient/NpipeSocketClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/NpipeSocketClientProviderStrategy.java index fc3c65fd3dd..0e1f641fe8a 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/NpipeSocketClientProviderStrategy.java +++ b/core/src/main/java/org/testcontainers/dockerclient/NpipeSocketClientProviderStrategy.java @@ -1,6 +1,6 @@ package org.testcontainers.dockerclient; -import org.apache.commons.lang.SystemUtils; +import org.apache.commons.lang3.SystemUtils; import java.net.URI; @@ -12,15 +12,14 @@ 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(); + return TransportConfig.builder().dockerHost(URI.create(SOCKET_LOCATION)).build(); } @Override diff --git a/core/src/main/java/org/testcontainers/dockerclient/RootlessDockerClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/RootlessDockerClientProviderStrategy.java index 3ee58f4910a..a032c2b324d 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/RootlessDockerClientProviderStrategy.java +++ b/core/src/main/java/org/testcontainers/dockerclient/RootlessDockerClientProviderStrategy.java @@ -4,8 +4,8 @@ import com.sun.jna.Native; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang.StringUtils; -import org.apache.commons.lang.SystemUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.SystemUtils; import org.jetbrains.annotations.Nullable; import java.net.URI; @@ -29,13 +29,15 @@ public final class RootlessDockerClientProviderStrategy extends DockerClientProv 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); + 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() { @@ -72,9 +74,7 @@ private Optional tryFolder(Path path) { @Override public TransportConfig getTransportConfig() throws InvalidConfigurationException { - return TransportConfig.builder() - .dockerHost(URI.create("unix://" + getSocketPath().toString())) - .build(); + return TransportConfig.builder().dockerHost(URI.create("unix://" + getSocketPath().toString())).build(); } @Override @@ -93,10 +93,8 @@ protected int getPriority() { } 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/UnixSocketClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/UnixSocketClientProviderStrategy.java index 6f030ada279..c262bda1db5 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/UnixSocketClientProviderStrategy.java +++ b/core/src/main/java/org/testcontainers/dockerclient/UnixSocketClientProviderStrategy.java @@ -1,6 +1,6 @@ package org.testcontainers.dockerclient; -import org.apache.commons.lang.SystemUtils; +import org.apache.commons.lang3.SystemUtils; import java.io.IOException; import java.net.URI; @@ -14,8 +14,11 @@ */ @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; public static final int PRIORITY = EnvironmentAndSystemPropertyClientProviderStrategy.PRIORITY - 20; @@ -31,12 +34,12 @@ public TransportConfig getTransportConfig() throws InvalidConfigurationException } 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" + ); } - return TransportConfig.builder() - .dockerHost(URI.create(SOCKET_LOCATION)) - .build(); + return TransportConfig.builder().dockerHost(URI.create(SOCKET_LOCATION)).build(); } @Override diff --git a/core/src/main/java/org/testcontainers/images/AbstractImagePullPolicy.java b/core/src/main/java/org/testcontainers/images/AbstractImagePullPolicy.java index f5d75ad9617..1f9583ef4cf 100644 --- a/core/src/main/java/org/testcontainers/images/AbstractImagePullPolicy.java +++ b/core/src/main/java/org/testcontainers/images/AbstractImagePullPolicy.java @@ -36,7 +36,6 @@ public boolean shouldPull(DockerImageName imageName) { log.debug("Using locally available and not pulling image: {}", imageName); return false; } - } /** @@ -45,5 +44,5 @@ public boolean shouldPull(DockerImageName imageName) { * * @return {@code true} to update the locally available image, {@code false} to use local instead */ - abstract protected boolean shouldPullCached(DockerImageName imageName, ImageData localImageData); + protected abstract boolean shouldPullCached(DockerImageName imageName, ImageData localImageData); } diff --git a/core/src/main/java/org/testcontainers/images/ImageData.java b/core/src/main/java/org/testcontainers/images/ImageData.java index 345b5939268..5b9f8227924 100644 --- a/core/src/main/java/org/testcontainers/images/ImageData.java +++ b/core/src/main/java/org/testcontainers/images/ImageData.java @@ -17,14 +17,16 @@ public class ImageData { Instant createdAt; static ImageData from(InspectImageResponse inspectImageResponse) { - return ImageData.builder() - .createdAt(ZonedDateTime.parse(inspectImageResponse.getCreated()).toInstant()) - .build(); + 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) { - return ImageData.builder() - .createdAt(Instant.ofEpochMilli(image.getCreated())) - .build(); + 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 index e821e511795..a090c3f7b9c 100644 --- a/core/src/main/java/org/testcontainers/images/ImagePullPolicy.java +++ b/core/src/main/java/org/testcontainers/images/ImagePullPolicy.java @@ -3,7 +3,5 @@ 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 index ec371ef830e..c98339e9f4c 100644 --- a/core/src/main/java/org/testcontainers/images/LocalImagesCache.java +++ b/core/src/main/java/org/testcontainers/images/LocalImagesCache.java @@ -21,20 +21,20 @@ enum LocalImagesCache { INSTANCE; - private final AtomicBoolean initialized = new AtomicBoolean(false); + @VisibleForTesting + final AtomicBoolean initialized = new AtomicBoolean(false); @VisibleForTesting final Map cache = new ConcurrentHashMap<>(); - DockerClient dockerClient = DockerClientFactory.lazyClient(); - public ImageData get(DockerImageName imageName) { - maybeInitCache(); + maybeInitCache(DockerClientFactory.instance().client()); return cache.get(imageName); } public Optional refreshCache(DockerImageName imageName) { - if (!maybeInitCache()) { + DockerClient dockerClient = DockerClientFactory.instance().client(); + if (!maybeInitCache(dockerClient)) { // Cache may be stale, trying inspectImageCmd... InspectImageResponse response = null; @@ -56,7 +56,7 @@ public Optional refreshCache(DockerImageName imageName) { return Optional.ofNullable(cache.get(imageName)); } - private synchronized boolean maybeInitCache() { + private synchronized boolean maybeInitCache(DockerClient dockerClient) { if (!initialized.compareAndSet(false, true)) { return false; } @@ -79,14 +79,12 @@ private void populateFromList(List images) { } cache.putAll( - Stream.of(repoTags) + 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) - )) + .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 index 2ba8ba6df11..73af8a05031 100644 --- a/core/src/main/java/org/testcontainers/images/LoggedPullImageResultCallback.java +++ b/core/src/main/java/org/testcontainers/images/LoggedPullImageResultCallback.java @@ -2,28 +2,37 @@ 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.*; - -import static java.lang.String.format; -import static org.apache.commons.io.FileUtils.byteCountToDisplaySize; +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) { @@ -69,8 +78,7 @@ public void onNext(final PullResponseItem item) { } } - if (statusLowercase.startsWith("pulling from" ) || statusLowercase.contains("complete" )) { - + if (statusLowercase.startsWith("pulling from") || statusLowercase.contains("complete")) { long totalSize = totalLayerSize(); long currentSize = downloadedLayerSize(); @@ -79,15 +87,17 @@ public void onNext(final PullResponseItem item) { if (pendingCount > 0) { friendlyTotalSize = "? MB"; } else { - friendlyTotalSize = byteCountToDisplaySize(totalSize); + friendlyTotalSize = FileUtils.byteCountToDisplaySize(totalSize); } - logger.info("Pulling image layers: {} pending, {} downloaded, {} extracted, ({}/{})", - format("%2d", pendingCount), - format("%2d", downloadedLayers.size()), - format("%2d", pulledLayers.size()), - byteCountToDisplaySize(currentSize), - friendlyTotalSize); + 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")) { @@ -103,11 +113,13 @@ public void onComplete() { final long duration = Duration.between(start, Instant.now()).getSeconds(); if (completed) { - logger.info("Pull complete. {} layers, pulled in {}s (downloaded {} at {}/s)", + logger.info( + "Pull complete. {} layers, pulled in {}s (downloaded {} at {}/s)", allLayers.size(), duration, - byteCountToDisplaySize(downloadedLayerSize), - byteCountToDisplaySize(downloadedLayerSize / duration)); + FileUtils.byteCountToDisplaySize(downloadedLayerSize), + FileUtils.byteCountToDisplaySize(downloadedLayerSize / duration) + ); } } diff --git a/core/src/main/java/org/testcontainers/images/ParsedDockerfile.java b/core/src/main/java/org/testcontainers/images/ParsedDockerfile.java index d4574d17c34..8c4d7866ff4 100644 --- a/core/src/main/java/org/testcontainers/images/ParsedDockerfile.java +++ b/core/src/main/java/org/testcontainers/images/ParsedDockerfile.java @@ -21,22 +21,25 @@ @Slf4j public class ParsedDockerfile { - private static final Pattern FROM_LINE_PATTERN = Pattern.compile("FROM (?--[^\\s]+\\s)*(?[^\\s]+).*", Pattern.CASE_INSENSITIVE); + private static final Pattern FROM_LINE_PATTERN = Pattern.compile( + "FROM (?--[^\\s]+\\s)*(?[^\\s]+).*", + Pattern.CASE_INSENSITIVE + ); private final Path dockerFilePath; @Getter - private Set dependencyImageNames = Collections.emptySet(); + private final Set dependencyImageNames; public ParsedDockerfile(Path dockerFilePath) { this.dockerFilePath = dockerFilePath; - parse(read()); + this.dependencyImageNames = parse(read()); } @VisibleForTesting ParsedDockerfile(List lines) { this.dockerFilePath = Paths.get("dummy.Dockerfile"); - parse(lines); + this.dependencyImageNames = parse(lines); } private List read() { @@ -53,15 +56,17 @@ private List read() { } } - private void parse(List lines) { - dependencyImageNames = lines.stream() + 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 (!dependencyImageNames.isEmpty()) { - log.debug("Found dependency images in Dockerfile {}: {}", dockerFilePath, dependencyImageNames); + 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 index 3dc2e452b8d..8c3a067cc15 100644 --- a/core/src/main/java/org/testcontainers/images/PullPolicy.java +++ b/core/src/main/java/org/testcontainers/images/PullPolicy.java @@ -1,22 +1,64 @@ package org.testcontainers.images; -import java.time.Duration; - +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 ImagePullPolicy defaultPolicy() { - return new DefaultPullPolicy(); + 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; } /** @@ -34,5 +76,4 @@ public static ImagePullPolicy alwaysPull() { 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 f4b04712913..fdd7279f86b 100644 --- a/core/src/main/java/org/testcontainers/images/RemoteDockerImage.java +++ b/core/src/main/java/org/testcontainers/images/RemoteDockerImage.java @@ -1,15 +1,20 @@ 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.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.experimental.Wither; +import lombok.With; +import org.awaitility.Awaitility; +import org.awaitility.pollinterval.IterativePollInterval; +import org.awaitility.pollinterval.PollInterval; import org.slf4j.Logger; import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.ContainerFetchException; @@ -17,24 +22,32 @@ 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 { - private static final Duration PULL_RETRY_TIME_LIMIT = Duration.ofMinutes(2); + private static final Duration PULL_RETRY_TIME_LIMIT = Duration.ofSeconds( + TestcontainersConfiguration.getInstance().getImagePullTimeout() + ); @ToString.Exclude private Future imageNameFuture; - @Wither - private ImagePullPolicy imagePullPolicy = PullPolicy.defaultPolicy(); + @With + ImagePullPolicy imagePullPolicy = PullPolicy.defaultPolicy(); + + @With + private ImageNameSubstitutor imageNameSubstitutor = ImageNameSubstitutor.instance(); @ToString.Exclude private DockerClient dockerClient = DockerClientFactory.lazyClient(); @@ -58,53 +71,109 @@ public RemoteDockerImage(@NonNull Future imageFuture) { } @Override - @SneakyThrows({InterruptedException.class, ExecutionException.class}) + @SneakyThrows({ InterruptedException.class, ExecutionException.class }) protected final String resolve() { final DockerImageName imageName = getImageName(); - Logger logger = DockerLoggerFactory.getLogger(imageName.toString()); + final Logger logger = DockerLoggerFactory.getLogger(imageName.toString()); try { 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); + logger.info( + "Pulling docker image: {}. Please be patient; this may take some time but only needs to be done once.", + imageName + ); - Exception lastFailure = null; + final Instant startedAt = Instant.now(); final Instant lastRetryAllowed = Instant.now().plus(PULL_RETRY_TIME_LIMIT); - - while (Instant.now().isBefore(lastRetryAllowed)) { - try { - dockerClient - .pullImageCmd(imageName.getUnversionedPart()) - .withTag(imageName.getVersionPart()) - .exec(new TimeLimitedLoggedPullImageResultCallback(logger)) - .awaitCompletion(); - - LocalImagesCache.INSTANCE.refreshCache(imageName); - - return imageName.asCanonicalNameString(); - } catch (InterruptedException | InternalServerErrorException e) { - // these classes of exception often relate to timeout/connection errors so should be retried - lastFailure = e; - logger.warn("Retrying pull for image: {} ({}s remaining)", - imageName, - Duration.between(Instant.now(), lastRetryAllowed).getSeconds()); - } + 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); } - logger.error("Failed to pull image: {}. Please check output of `docker pull {}`", imageName, imageName, lastFailure); - throw new ContainerFetchException("Failed to pull image: " + imageName, lastFailure); + 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 " + 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.instance().apply(specifiedImageName); + return imageNameSubstitutor.apply(specifiedImageName); } @ToString.Include(name = "imageName", rank = 1) diff --git a/core/src/main/java/org/testcontainers/images/TimeLimitedLoggedPullImageResultCallback.java b/core/src/main/java/org/testcontainers/images/TimeLimitedLoggedPullImageResultCallback.java index b6d8539c3e8..88b8804dd8a 100644 --- a/core/src/main/java/org/testcontainers/images/TimeLimitedLoggedPullImageResultCallback.java +++ b/core/src/main/java/org/testcontainers/images/TimeLimitedLoggedPullImageResultCallback.java @@ -3,6 +3,7 @@ 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; @@ -16,8 +17,6 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import static org.testcontainers.DockerClientFactory.TESTCONTAINERS_THREAD_GROUP; - /** * {@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 @@ -25,14 +24,21 @@ 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(TESTCONTAINERS_THREAD_GROUP, runnable); + + 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 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 @@ -85,27 +91,30 @@ public void onComplete() { 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()) { + if (nextCheckForProgress != null && !nextCheckForProgress.isCancelled()) { nextCheckForProgress.cancel(false); } if (!isFinished) { - nextCheckForProgress = PROGRESS_WATCHDOG_EXECUTOR.schedule( - this::abortPull, - PULL_PAUSE_TOLERANCE.getSeconds(), - TimeUnit.SECONDS - ); + 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()); + 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); 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 b36779f1b9e..ced612bfefa 100644 --- a/core/src/main/java/org/testcontainers/images/builder/ImageFromDockerfile.java +++ b/core/src/main/java/org/testcontainers/images/builder/ImageFromDockerfile.java @@ -9,17 +9,20 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; import org.apache.commons.io.FileUtils; -import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.testcontainers.DockerClientFactory; 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; @@ -29,14 +32,19 @@ 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, @@ -48,9 +56,17 @@ public class ImageFromDockerfile extends LazyFuture implements 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() { @@ -81,13 +97,10 @@ public ImageFromDockerfile withFileFromTransferable(String path, Transferable tr protected final String resolve() { Logger logger = DockerLoggerFactory.getLogger(dockerImageName); + //noinspection resource DockerClient dockerClient = DockerClientFactory.instance().client(); try { - if (deleteOnExit) { - ResourceReaper.instance().registerImageForCleanup(dockerImageName); - } - BuildImageResultCallback resultCallback = new BuildImageResultCallback() { @Override public void onNext(BuildResponseItem item) { @@ -96,14 +109,16 @@ 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); BuildImageCmd buildImageCmd = dockerClient.buildImageCmd(in); configure(buildImageCmd); @@ -111,7 +126,12 @@ public void onNext(BuildResponseItem item) { 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); @@ -123,6 +143,7 @@ public void onNext(BuildResponseItem item) { // 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()) { Transferable transferable = entry.getValue(); @@ -134,48 +155,75 @@ public void onNext(BuildResponseItem item) { } log.info("Transferred {} to Docker daemon", FileUtils.byteCountToDisplaySize(bytesToDockerDaemon)); - if (bytesToDockerDaemon > FileUtils.ONE_MB * 50) // warn if >50MB sent to docker daemon - log.warn("A large amount of data was sent to the Docker daemon ({}). Consider using a .dockerignore file for better performance.", - 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); } } 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(); + 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); - } - }); + 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) { - final DockerClient dockerClient = DockerClientFactory.instance().client(); - 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.", imageName); - DockerClientFactory.instance().checkAndPullImage(dockerClient, imageName); + 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: {}", imageName, e.getMessage()); + 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; @@ -186,6 +234,16 @@ public ImageFromDockerfile withBuildArgs(final Map 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. * @@ -211,4 +269,15 @@ public ImageFromDockerfile withDockerfile(Path 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 3f068f98a18..32bf199b8a8 100644 --- a/core/src/main/java/org/testcontainers/images/builder/Transferable.java +++ b/core/src/main/java/org/testcontainers/images/builder/Transferable.java @@ -5,12 +5,22 @@ import org.apache.commons.io.IOUtils; 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); } @@ -27,6 +37,11 @@ public byte[] getBytes() { return bytes; } + @Override + public void updateChecksum(Checksum checksum) { + checksum.update(bytes, 0, bytes.length); + } + @Override public int getFileMode() { return fileMode; @@ -78,4 +93,8 @@ default byte[] getBytes() { 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 07077259df8..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,7 +4,6 @@ import org.testcontainers.utility.DockerImageName; public interface FromStatementTrait & DockerfileBuilderTrait> { - default SELF from(String dockerImageName) { DockerImageName.parse(dockerImageName).assertValid(); 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 07ed13ed0fb..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 @@ -10,29 +10,31 @@ * 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 String getDescription() { - return "Dockerfile: " + builder; - } - }); + 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 61524b59f8a..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 @@ -10,7 +10,6 @@ * */ public interface FilesTrait & BuildContextBuilderTrait> { - /** * Adds file to tarball copied into container. * @param path in tarball 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 51423068ec9..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,7 +1,7 @@ package org.testcontainers.images.builder.traits; import lombok.Getter; -import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang3.StringUtils; import org.testcontainers.images.builder.Transferable; /** @@ -9,22 +9,23 @@ * */ public interface StringsTrait & BuildContextBuilderTrait> { - default SELF withFileFromString(String path, String content) { - return ((SELF) this).withFileFromTransferable(path, new Transferable() { - - @Getter - byte[] bytes = content.getBytes(); + return ((SELF) this).withFileFromTransferable( + path, + new Transferable() { + @Getter + byte[] bytes = content.getBytes(); - @Override - public long getSize() { - return bytes.length; - } + @Override + public long getSize() { + return bytes.length; + } - @Override - public String getDescription() { - return "String: " + StringUtils.abbreviate(content, 100); - } - }); + @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 index 3e73968d06e..2ffa423d2e3 100644 --- a/core/src/main/java/org/testcontainers/lifecycle/Startable.java +++ b/core/src/main/java/org/testcontainers/lifecycle/Startable.java @@ -4,7 +4,6 @@ import java.util.Set; public interface Startable extends AutoCloseable { - default Set getDependencies() { return Collections.emptySet(); } diff --git a/core/src/main/java/org/testcontainers/lifecycle/Startables.java b/core/src/main/java/org/testcontainers/lifecycle/Startables.java index 5ec46f1edb9..d61919af346 100644 --- a/core/src/main/java/org/testcontainers/lifecycle/Startables.java +++ b/core/src/main/java/org/testcontainers/lifecycle/Startables.java @@ -1,8 +1,8 @@ package org.testcontainers.lifecycle; -import java.util.Arrays; import lombok.experimental.UtilityClass; +import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.Map; @@ -17,17 +17,18 @@ @UtilityClass public class Startables { - private static final Executor EXECUTOR = Executors.newCachedThreadPool(new ThreadFactory() { + private static final Executor EXECUTOR = Executors.newCachedThreadPool( + new ThreadFactory() { + private final AtomicLong COUNTER = new AtomicLong(0); - 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; + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "testcontainers-lifecycle-" + COUNTER.getAndIncrement()); + thread.setDaemon(true); + return thread; + } } - }); + ); /** * @see #deepStart(Stream) @@ -77,20 +78,40 @@ public CompletableFuture deepStart(Stream 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) { + 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); - }); + 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 CompletableFuture.allOf(futures); + 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 index c27f59fb0fc..3787707509a 100644 --- a/core/src/main/java/org/testcontainers/lifecycle/TestDescription.java +++ b/core/src/main/java/org/testcontainers/lifecycle/TestDescription.java @@ -1,7 +1,6 @@ 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 index 178e081c083..1e5a0258f14 100644 --- a/core/src/main/java/org/testcontainers/lifecycle/TestLifecycleAware.java +++ b/core/src/main/java/org/testcontainers/lifecycle/TestLifecycleAware.java @@ -3,12 +3,7 @@ import java.util.Optional; public interface TestLifecycleAware { + default void beforeTest(TestDescription description) {} - default void beforeTest(TestDescription description) { - - } - - default void afterTest(TestDescription description, Optional throwable) { - - } + 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 index 567132da05d..518144c4302 100644 --- a/core/src/main/java/org/testcontainers/utility/AuditLogger.java +++ b/core/src/main/java/org/testcontainers/utility/AuditLogger.java @@ -3,17 +3,16 @@ 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.lang.StringUtils; +import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.MDC; import java.util.List; -import static com.google.common.base.Strings.nullToEmpty; - /** * 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. @@ -26,34 +25,36 @@ 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) { + 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()) { + 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", nullToEmpty(action)); - MDC.put(MDC_PREFIX + ".Image", nullToEmpty(image)); - MDC.put(MDC_PREFIX + ".ContainerId", nullToEmpty(containerId)); + 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) { - } + } catch (JsonProcessingException ignored) {} if (e != null) { MDC.put(MDC_PREFIX + ".Exception", e.getLocalizedMessage()); @@ -69,10 +70,8 @@ public static void doLog(@NotNull String action, MDC.remove(MDC_PREFIX + ".Exception"); } - public static void doComposeLog(@NotNull String[] commandParts, - @Nullable List env) { - - if (! log.isTraceEnabled()) { + public static void doComposeLog(@NotNull String[] commandParts, @Nullable List env) { + if (!log.isTraceEnabled()) { return; } diff --git a/core/src/main/java/org/testcontainers/utility/AuthConfigUtil.java b/core/src/main/java/org/testcontainers/utility/AuthConfigUtil.java index 77b670d374b..8ed91a5e34e 100644 --- a/core/src/main/java/org/testcontainers/utility/AuthConfigUtil.java +++ b/core/src/main/java/org/testcontainers/utility/AuthConfigUtil.java @@ -2,22 +2,23 @@ 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; -import static com.google.common.base.Strings.isNullOrEmpty; - /** * TODO: Javadocs */ @UtilityClass public class AuthConfigUtil { + public static String toSafeString(AuthConfig authConfig) { if (authConfig == null) { return "null"; } - return MoreObjects.toStringHelper(authConfig) + return MoreObjects + .toStringHelper(authConfig) .add("username", authConfig.getUsername()) .add("password", obfuscated(authConfig.getPassword())) .add("auth", obfuscated(authConfig.getAuth())) @@ -29,6 +30,6 @@ public static String toSafeString(AuthConfig authConfig) { @NotNull private static String obfuscated(String value) { - return isNullOrEmpty(value) ? "blank" : "hidden non-blank 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 index 76716cfdb7c..0c8addc2874 100644 --- a/core/src/main/java/org/testcontainers/utility/ClasspathScanner.java +++ b/core/src/main/java/org/testcontainers/utility/ClasspathScanner.java @@ -40,11 +40,7 @@ private static Integer filesFileSchemeFirst(final URL t) { * context classloader. Results are currently alphabetically sorted. */ static Stream scanFor(final String name) { - return scanFor( - name, - ClasspathScanner.class.getClassLoader(), - Thread.currentThread().getContextClassLoader() - ); + return scanFor(name, ClasspathScanner.class.getClassLoader(), Thread.currentThread().getContextClassLoader()); } @Nullable diff --git a/core/src/main/java/org/testcontainers/utility/CommandLine.java b/core/src/main/java/org/testcontainers/utility/CommandLine.java index a217823c40f..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,16 +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 = 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) { @@ -55,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()) { @@ -78,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 d3e3d9eb958..999c4b40b3b 100644 --- a/core/src/main/java/org/testcontainers/utility/ComparableVersion.java +++ b/core/src/main/java/org/testcontainers/utility/ComparableVersion.java @@ -1,12 +1,11 @@ package org.testcontainers.utility; -import java.util.ArrayList; -import java.util.List; - import com.google.common.annotations.VisibleForTesting; - import org.jetbrains.annotations.NotNull; +import java.util.ArrayList; +import java.util.List; + public final class ComparableVersion implements Comparable { private final int[] parts; @@ -19,7 +18,7 @@ public ComparableVersion(String version) { @Override public int compareTo(@NotNull ComparableVersion other) { - for (int i=0; i otherPart) { @@ -71,5 +70,4 @@ static int[] parseVersion(final String version) { return ret; } - } diff --git a/core/src/main/java/org/testcontainers/utility/ConfigurationFileImageNameSubstitutor.java b/core/src/main/java/org/testcontainers/utility/ConfigurationFileImageNameSubstitutor.java index 530d5a76118..ee7ab1d95b6 100644 --- a/core/src/main/java/org/testcontainers/utility/ConfigurationFileImageNameSubstitutor.java +++ b/core/src/main/java/org/testcontainers/utility/ConfigurationFileImageNameSubstitutor.java @@ -28,7 +28,8 @@ public DockerImageName apply(final DockerImageName 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", + log.warn( + "Image name {} was substituted by configuration to {}. This approach is deprecated and will be removed in the future", original, result ); diff --git a/core/src/main/java/org/testcontainers/utility/DefaultImageNameSubstitutor.java b/core/src/main/java/org/testcontainers/utility/DefaultImageNameSubstitutor.java index 840b8a77b40..a7755f8eea8 100644 --- a/core/src/main/java/org/testcontainers/utility/DefaultImageNameSubstitutor.java +++ b/core/src/main/java/org/testcontainers/utility/DefaultImageNameSubstitutor.java @@ -11,6 +11,7 @@ final class DefaultImageNameSubstitutor extends ImageNameSubstitutor { private final ConfigurationFileImageNameSubstitutor configurationFileImageNameSubstitutor; + private final PrefixingImageNameSubstitutor prefixingImageNameSubstitutor; public DefaultImageNameSubstitutor() { @@ -29,13 +30,17 @@ public DefaultImageNameSubstitutor() { @Override public DockerImageName apply(final DockerImageName original) { - return configurationFileImageNameSubstitutor - .andThen(prefixingImageNameSubstitutor) - .apply(original); + return configurationFileImageNameSubstitutor.andThen(prefixingImageNameSubstitutor).apply(original); } @Override protected String getDescription() { - return "DefaultImageNameSubstitutor (composite of '" + configurationFileImageNameSubstitutor.getDescription() + "' and '" + prefixingImageNameSubstitutor.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 b9d266a41ca..b3babc29c91 100644 --- a/core/src/main/java/org/testcontainers/utility/DockerImageName.java +++ b/core/src/main/java/org/testcontainers/utility/DockerImageName.java @@ -1,6 +1,5 @@ package org.testcontainers.utility; - import com.google.common.net.HostAndPort; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -20,16 +19,31 @@ public final class DockerImageName { /* 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) + + @With + @Getter + private final String registry; + + @With + @Getter + private final String repository; + + @NotNull + @With(AccessLevel.PRIVATE) private final Versioning versioning; - @Nullable @With(AccessLevel.PRIVATE) + + @Nullable + @With(AccessLevel.PRIVATE) private final DockerImageName compatibleSubstituteFor; /** @@ -57,10 +71,14 @@ public DockerImageName(String fullImageName) { final int slashIndex = fullImageName.indexOf('/'); String remoteName; - if (slashIndex == -1 || - (!fullImageName.substring(0, slashIndex).contains(".") && + if ( + slashIndex == -1 || + ( + !fullImageName.substring(0, slashIndex).contains(".") && !fullImageName.substring(0, slashIndex).contains(":") && - !fullImageName.substring(0, slashIndex).equals("localhost"))) { + !fullImageName.substring(0, slashIndex).equals("localhost") + ) + ) { registry = ""; remoteName = fullImageName; } else { @@ -69,8 +87,15 @@ public DockerImageName(String fullImageName) { } if (remoteName.contains("@sha256:")) { - repository = remoteName.split("@sha256:")[0]; - versioning = new Sha256Versioning(remoteName.split("@sha256:")[1]); + 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]); @@ -99,10 +124,14 @@ public DockerImageName(String nameWithoutTag, @NotNull String version) { final int slashIndex = nameWithoutTag.indexOf('/'); String remoteName; - if (slashIndex == -1 || - (!nameWithoutTag.substring(0, slashIndex).contains(".") && + if ( + slashIndex == -1 || + ( + !nameWithoutTag.substring(0, slashIndex).contains(".") && !nameWithoutTag.substring(0, slashIndex).contains(":") && - !nameWithoutTag.substring(0, slashIndex).equals("localhost"))) { + !nameWithoutTag.substring(0, slashIndex).equals("localhost") + ) + ) { registry = ""; remoteName = nameWithoutTag; } else { @@ -133,16 +162,40 @@ public String getUnversionedPart() { } /** - * @return the versioned part of this name (tag or sha256) + * @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(); } @@ -163,7 +216,9 @@ public void assertValid() { 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 + ")"); + throw new IllegalArgumentException( + versioning + " is not a valid image versioning identifier (in " + rawName + ")" + ); } } @@ -209,8 +264,15 @@ public DockerImageName asCompatibleSubstituteFor(DockerImageName otherImageName) * @return whether this image has declared compatibility. */ public boolean isCompatibleWith(DockerImageName other) { - // is this image already the same or equivalent? - if (other.equals(this)) { + // 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 { + finalImageName = LIBRARY_PREFIX + this.repository; + } + DockerImageName imageWithLibraryPrefix = DockerImageName.parse(finalImageName); + if (other.equals(this) || imageWithLibraryPrefix.equals(this)) { return true; } @@ -246,18 +308,16 @@ public void assertCompatibleWith(DockerImageName... anyOthers) { 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 + "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 69f7b998da2..e45c5867dd9 100644 --- a/core/src/main/java/org/testcontainers/utility/DockerLoggerFactory.java +++ b/core/src/main/java/org/testcontainers/utility/DockerLoggerFactory.java @@ -9,7 +9,6 @@ public final class DockerLoggerFactory { public static Logger getLogger(String dockerImageName) { - final String abbreviatedName; if (dockerImageName.contains("@sha256")) { abbreviatedName = dockerImageName.substring(0, dockerImageName.indexOf("@sha256") + 14) + "..."; @@ -17,10 +16,6 @@ public static Logger getLogger(String dockerImageName) { abbreviatedName = dockerImageName; } - if ("UTF-8".equals(System.getProperty("file.encoding"))) { - return LoggerFactory.getLogger("\uD83D\uDC33 [" + abbreviatedName + "]"); - } else { - return LoggerFactory.getLogger("docker[" + abbreviatedName + "]"); - } + 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 021baf270d9..1a2b4aafe74 100644 --- a/core/src/main/java/org/testcontainers/utility/DockerMachineClient.java +++ b/core/src/main/java/org/testcontainers/utility/DockerMachineClient.java @@ -1,46 +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(); } @@ -49,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"); @@ -65,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(); @@ -75,7 +76,7 @@ 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); } } @@ -84,15 +85,15 @@ public void ensureMachineRunning(@NonNull String machineName) { */ @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 runShellCommand(executableName, "url", 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 index 645b717a562..e46d091d657 100644 --- a/core/src/main/java/org/testcontainers/utility/ImageNameSubstitutor.java +++ b/core/src/main/java/org/testcontainers/utility/ImageNameSubstitutor.java @@ -4,7 +4,9 @@ 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. @@ -24,25 +26,28 @@ public abstract class ImageNameSubstitutor implements Function 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 758082f5185..3fb2ed704c0 100644 --- a/core/src/main/java/org/testcontainers/utility/LazyFuture.java +++ b/core/src/main/java/org/testcontainers/utility/LazyFuture.java @@ -4,7 +4,9 @@ import lombok.Getter; 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; /** @@ -17,7 +19,7 @@ public abstract class LazyFuture implements Future { @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) { diff --git a/core/src/main/java/org/testcontainers/utility/LicenseAcceptance.java b/core/src/main/java/org/testcontainers/utility/LicenseAcceptance.java index 55037d8978c..9fe8d9db8e6 100644 --- a/core/src/main/java/org/testcontainers/utility/LicenseAcceptance.java +++ b/core/src/main/java/org/testcontainers/utility/LicenseAcceptance.java @@ -27,10 +27,17 @@ public static void assertLicenseAccepted(final String imageName) { // 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); - + 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 ce27387490f..e5912563cd9 100644 --- a/core/src/main/java/org/testcontainers/utility/LogUtils.java +++ b/core/src/main/java/org/testcontainers/utility/LogUtils.java @@ -13,9 +13,6 @@ 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. */ @@ -31,11 +28,12 @@ public class LogUtils { * @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) { - + public void followOutput( + DockerClient dockerClient, + String containerId, + Consumer consumer, + OutputFrame.OutputType... types + ) { attachConsumer(dockerClient, containerId, consumer, true, types); } @@ -47,11 +45,8 @@ public void followOutput(DockerClient dockerClient, * @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, STDOUT, STDERR); + public void followOutput(DockerClient dockerClient, String containerId, Consumer consumer) { + followOutput(dockerClient, containerId, consumer, OutputFrame.OutputType.STDOUT, OutputFrame.OutputType.STDERR); } /** @@ -63,16 +58,13 @@ public void followOutput(DockerClient dockerClient, * @return all previous output frames (stdout/stderr being separated by newline characters) */ @SneakyThrows(IOException.class) - public String getOutput(DockerClient dockerClient, - String containerId, - OutputFrame.OutputType... types) { - + public String getOutput(DockerClient dockerClient, String containerId, OutputFrame.OutputType... types) { if (containerId == null) { return ""; } if (types.length == 0) { - types = new OutputFrame.OutputType[] { STDOUT, STDERR }; + types = new OutputFrame.OutputType[] { OutputFrame.OutputType.STDOUT, OutputFrame.OutputType.STDERR }; } final ToStringConsumer consumer = new ToStringConsumer(); @@ -90,16 +82,20 @@ private static Closeable attachConsumer( boolean followStream, OutputFrame.OutputType... types ) { - - final LogContainerCmd cmd = dockerClient.logContainerCmd(containerId) + 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); + } } 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 1982538f45d..a8a9f465a19 100644 --- a/core/src/main/java/org/testcontainers/utility/MountableFile.java +++ b/core/src/main/java/org/testcontainers/utility/MountableFile.java @@ -1,13 +1,16 @@ 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.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; import org.apache.commons.compress.archivers.tar.TarConstants; -import org.apache.commons.lang.SystemUtils; +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; @@ -28,24 +31,27 @@ import java.util.Set; import java.util.jar.JarEntry; import java.util.jar.JarFile; - -import static lombok.AccessLevel.PACKAGE; -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 = PACKAGE) +@RequiredArgsConstructor(access = AccessLevel.PACKAGE) @Slf4j 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) @@ -119,10 +125,11 @@ 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) { - + 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()); @@ -130,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; @@ -144,16 +155,22 @@ 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: " + classLoadersToSearch); + 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 URLDecoder.decode(resource.replaceAll("\\+", "%2B"), Charsets.UTF_8.name()) - .replaceFirst("jar:", "") - .replaceFirst("file:", "") - .replaceAll("!.*", ""); + return URLDecoder + .decode(resource.replaceAll("\\+", "%2B"), Charsets.UTF_8.name()) + .replaceFirst("jar:", "") + .replaceFirst("file:", "") + .replaceAll("!.*", ""); } catch (UnsupportedEncodingException e) { throw new IllegalStateException(e); } @@ -228,15 +245,19 @@ 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 @@ -255,23 +276,22 @@ private File createTempDirectory() { 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) { + } 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 { - + 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 @@ -282,14 +302,21 @@ private void copyFromJarToLocation(final JarFile jarFile, 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(DockerClientFactory.TESTCONTAINERS_THREAD_GROUP, () -> recursiveDeleteDir(path))); + Runtime + .getRuntime() + .addShutdownHook( + new Thread(DockerClientFactory.TESTCONTAINERS_THREAD_GROUP, () -> PathUtils.recursiveDeleteDir(path)) + ); } /** @@ -303,11 +330,20 @@ public void transferTo(final TarArchiveOutputStream outputStream, String destina /* * Recursively copies a file/directory into a TarArchiveOutputStream */ - private void recursiveTar(String entryFilename, String rootPath, String itemPath, TarArchiveOutputStream tarArchive) { + 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 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()) { @@ -332,7 +368,12 @@ private void recursiveTar(String entryFilename, String rootPath, String itemPath if (children != null) { // recurse into child files/directories for (final File child : children) { - recursiveTar(entryFilename, sourceRootFile.getCanonicalPath(), child.getCanonicalPath(), tarArchive); + recursiveTar( + entryFilename, + sourceRootFile.getCanonicalPath(), + child.getCanonicalPath(), + tarArchive + ); } } } catch (IOException e) { @@ -343,7 +384,6 @@ private void recursiveTar(String entryFilename, String rootPath, String itemPath @Override public long getSize() { - final File file = new File(this.getResolvedPath()); if (file.isFile()) { return file.length(); @@ -357,6 +397,29 @@ 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()); @@ -375,9 +438,11 @@ 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) || + if ( + "OS/390".equals(SystemUtils.OS_NAME) || "z/OS".equals(SystemUtils.OS_NAME) || - "zOS".equals(SystemUtils.OS_NAME) ) { + "zOS".equals(SystemUtils.OS_NAME) + ) { unixMode &= TarConstants.MAXID; unixMode |= Files.isDirectory(path) ? 040000 : 0100000; } 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 index c720416deac..7cf082000a4 100644 --- a/core/src/main/java/org/testcontainers/utility/PrefixingImageNameSubstitutor.java +++ b/core/src/main/java/org/testcontainers/utility/PrefixingImageNameSubstitutor.java @@ -38,11 +38,7 @@ public DockerImageName apply(DockerImageName original) { return original; } - log.debug( - "Applying changes to image name {}: applying prefix '{}'", - original, - configuredPrefix - ); + log.debug("Applying changes to image name {}: applying prefix '{}'", original, configuredPrefix); DockerImageName prefixAsImage = DockerImageName.parse(configuredPrefix); diff --git a/core/src/main/java/org/testcontainers/utility/RegistryAuthLocator.java b/core/src/main/java/org/testcontainers/utility/RegistryAuthLocator.java index 846b62f1f25..3caabb6f0d4 100644 --- a/core/src/main/java/org/testcontainers/utility/RegistryAuthLocator.java +++ b/core/src/main/java/org/testcontainers/utility/RegistryAuthLocator.java @@ -2,12 +2,18 @@ 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.lang.StringUtils; +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; @@ -21,25 +27,29 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import static org.apache.commons.lang.StringUtils.isBlank; -import static org.slf4j.LoggerFactory.getLogger; -import static org.testcontainers.utility.AuthConfigUtil.toSafeString; - /** * Utility to look up registry authentication information for an image. */ public class RegistryAuthLocator { - private static final Logger log = getLogger(RegistryAuthLocator.class); - private static final String DEFAULT_REGISTRY_NAME = "index.docker.io"; + 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<>(); /** @@ -49,9 +59,15 @@ public class RegistryAuthLocator { private final Map CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE; @VisibleForTesting - RegistryAuthLocator(File configFile, String commandPathPrefix, String commandExtension, - Map notFoundMessageHolderReference) { + RegistryAuthLocator( + File configFile, + String configEnv, + String commandPathPrefix, + String commandExtension, + Map notFoundMessageHolderReference + ) { this.configFile = configFile; + this.configEnv = configEnv; this.commandPathPrefix = commandPathPrefix; this.commandExtension = commandExtension; @@ -61,16 +77,18 @@ public class RegistryAuthLocator { /** */ protected RegistryAuthLocator() { - final String dockerConfigLocation = System.getenv().getOrDefault("DOCKER_CONFIG", - System.getProperty("user.home") + "/.docker"); + 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 synchronized static RegistryAuthLocator instance() { + public static synchronized RegistryAuthLocator instance() { if (instance == null) { instance = new RegistryAuthLocator(); } @@ -104,57 +122,87 @@ public AuthConfig lookupAuthConfig(DockerImageName dockerImageName, AuthConfig d 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)); + final Optional cachedAuth = cache.computeIfAbsent( + registryName, + __ -> lookupUncachedAuthConfig(registryName, dockerImageName) + ); if (cachedAuth.isPresent()) { - log.debug("Cached auth found: [{}]", toSafeString(cachedAuth.get())); + log.debug("Cached auth found: [{}]", AuthConfigUtil.toSafeString(cachedAuth.get())); return cachedAuth.get(); } else { - log.debug("No matching Auth Configs - falling back to defaultAuthConfig [{}]", toSafeString(defaultAuthConfig)); + 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) { - log.debug("RegistryAuthLocator has configFile: {} ({}) and commandPathPrefix: {}", - configFile, - configFile.exists() ? "exists" : "does not exist", - commandPathPrefix); - try { - final JsonNode config = OBJECT_MAPPER.readTree(configFile); + 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 [{}]", toSafeString(helperAuthConfig)); + 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 [{}]", toSafeString(storeAuthConfig)); + 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 [{}]", toSafeString(existingAuthConfig)); + 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: {}. Falling back to docker-java default behaviour. Exception message: {}", + 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, - e.getMessage()); + DOCKER_AUTH_ENV_VAR, + e.getMessage() + ); } return Optional.empty(); } - private AuthConfig findExistingAuthConfig(final JsonNode config, final String reposName) throws Exception { + 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) { @@ -162,10 +210,11 @@ private AuthConfig findExistingAuthConfig(final JsonNode config, final String re .treeToValue(entry.getValue(), AuthConfig.class) .withRegistryAddress(entry.getKey()); - if (isBlank(deserializedAuth.getUsername()) && - isBlank(deserializedAuth.getPassword()) && - !isBlank(deserializedAuth.getAuth())) { - + 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); @@ -196,7 +245,7 @@ private AuthConfig authConfigUsingStore(final JsonNode config, final String repo final JsonNode credsStoreNode = config.get("credsStore"); if (credsStoreNode != null && !credsStoreNode.isMissingNode() && credsStoreNode.isTextual()) { final String credsStore = credsStoreNode.asText(); - if (isBlank(credsStore)) { + if (StringUtils.isBlank(credsStore)) { log.warn("Docker auth config credsStore field will be ignored, because value is blank"); return null; } @@ -211,7 +260,7 @@ private Map.Entry findAuthNode(final JsonNode config, final St final Iterator> fields = auths.fields(); while (fields.hasNext()) { final Map.Entry entry = fields.next(); - if (entry.getKey().contains("://" + reposName) || entry.getKey().equals(reposName)) { + if (entry.getKey().endsWith("://" + reposName) || entry.getKey().equals(reposName)) { return entry; } } @@ -220,59 +269,68 @@ private Map.Entry findAuthNode(final JsonNode config, final St } private AuthConfig runCredentialProvider(String hostName, String helperOrStoreName) throws Exception { - - if (isBlank(hostName)) { + 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 String data; + final CredentialOutput data; - log.debug("Executing docker credential provider: {} to locate auth config for: {}", - credentialProgramName, hostName); + log.debug( + "Executing docker credential provider: {} to locate auth config for: {}", + credentialProgramName, + hostName + ); try { data = runCredentialProgram(hostName, credentialProgramName); - } catch (InvalidResultException e) { - - final String responseErrorMsg = extractCredentialProviderErrorMessage(e); - - if (!isBlank(responseErrorMsg)) { - String credentialsNotFoundMsg = getGenericCredentialsNotFoundMsg(credentialProgramName); - if (credentialsNotFoundMsg != null && credentialsNotFoundMsg.equals(responseErrorMsg)) { - log.info("Credential helper/store ({}) does not have credentials for {}", + 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, - hostName); - - return null; + responseErrorMsg, + data.getStderr() + ); + } else { + log.debug("Failure running docker credential helper/store ({})", credentialProgramName); } - log.debug("Failure running docker credential helper/store ({}) with output '{}'", - credentialProgramName, responseErrorMsg); - - } else { - log.debug("Failure running docker credential helper/store ({})", credentialProgramName); + throw new InvalidResultException(data.getStdout(), null); } - - throw e; } catch (Exception e) { log.debug("Failure running docker credential helper/store ({})", credentialProgramName); throw e; } - final JsonNode helperResponse = OBJECT_MAPPER.readTree(data); + 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 new AuthConfig().withIdentityToken(password); + return authConfig.withIdentityToken(password); } else { - return new AuthConfig() - .withRegistryAddress(helperResponse.at("/ServerURL").asText()) - .withUsername(username) - .withPassword(password); + return authConfig.withUsername(username).withPassword(password); } } @@ -281,70 +339,82 @@ private String getCredentialProgramName(String credHelper) { } private String effectiveRegistryName(DockerImageName dockerImageName) { - return StringUtils.defaultIfEmpty(dockerImageName.getRegistry(), DEFAULT_REGISTRY_NAME); + 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 credentialHelperName) { + private String getGenericCredentialsNotFoundMsg(String credentialsNotFoundMsg, String credentialHelperName) { if (!CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE.containsKey(credentialHelperName)) { - String credentialsNotFoundMsg = discoverCredentialsHelperNotFoundMessage(credentialHelperName); - if (!isBlank(credentialsNotFoundMsg)) { - CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE.put(credentialHelperName, credentialsNotFoundMsg); - } + CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE.put(credentialHelperName, credentialsNotFoundMsg); } - return CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE.get(credentialHelperName); } - private String discoverCredentialsHelperNotFoundMessage(String credentialHelperName) { - // will do fake call to given credential helper to find out with which message - // it response when there are no credentials for given hostName + 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(); - // hostName should be valid, but most probably not existing - // IF its not enough, then should probably run 'list' command first to be sure... - final String notExistentFakeHostName = "https://not.a.real.registry/url"; + return new CredentialOutput(exitValue, stdout.toString(), stderr.toString()); + } - String credentialsNotFoundMsg = null; - try { - runCredentialProgram(notExistentFakeHostName, credentialHelperName); - - // should not reach here - log.warn("Failure running docker credential helper ({}) with fake call, expected 'credentials not found' response", - credentialHelperName); - } catch(Exception e) { - if (e instanceof InvalidResultException) { - credentialsNotFoundMsg = extractCredentialProviderErrorMessage((InvalidResultException)e); - } + static class CredentialOutput { - if (isBlank(credentialsNotFoundMsg)) { - log.warn("Failure running docker credential helper ({}) with fake call, expected 'credentials not found' response. Exception message: {}", - credentialHelperName, - e.getMessage()); - } else { - log.debug("Got credentials not found error message from docker credential helper - {}", credentialsNotFoundMsg); - } + 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(); } - return credentialsNotFoundMsg; - } + int getExitValue() { + return this.exitValue; + } - private String extractCredentialProviderErrorMessage(InvalidResultException invalidResultEx) { - if (invalidResultEx.getResult() != null && invalidResultEx.getResult().hasOutput()) { - return invalidResultEx.getResult().outputString().trim(); + String getStdout() { + return this.stdout; } - return null; - } - private String runCredentialProgram(String hostName, String credentialHelperName) - throws InvalidResultException, InterruptedException, TimeoutException, IOException { - - return new ProcessExecutor() - .command(credentialHelperName, "get") - .redirectInput(new ByteArrayInputStream(hostName.getBytes())) - .readOutput(true) - .exitValueNormal() - .timeout(30, TimeUnit.SECONDS) - .execute() - .outputUTF8() - .trim(); + 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 7adbc95bb4c..ccea407ed3b 100644 --- a/core/src/main/java/org/testcontainers/utility/ResourceReaper.java +++ b/core/src/main/java/org/testcontainers/utility/ResourceReaper.java @@ -1,26 +1,18 @@ package org.testcontainers.utility; import com.github.dockerjava.api.DockerClient; -import com.github.dockerjava.api.async.ResultCallback; +import com.github.dockerjava.api.command.CreateContainerCmd; import com.github.dockerjava.api.command.InspectContainerResponse; import com.github.dockerjava.api.exception.NotFoundException; -import com.github.dockerjava.api.model.Bind; -import com.github.dockerjava.api.model.ExposedPort; -import com.github.dockerjava.api.model.Frame; -import com.github.dockerjava.api.model.HostConfig; import com.github.dockerjava.api.model.Network; -import com.github.dockerjava.api.model.Volume; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Throwables; import com.google.common.collect.Sets; -import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; -import org.rnorth.ducttape.ratelimits.RateLimiter; -import org.rnorth.ducttape.ratelimits.RateLimiterBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.testcontainers.DockerClientFactory; -import org.testcontainers.containers.ContainerState; +import org.testcontainers.containers.GenericContainer; import java.io.BufferedReader; import java.io.IOException; @@ -28,19 +20,15 @@ import java.io.InputStreamReader; import java.io.OutputStream; import java.io.UnsupportedEncodingException; -import java.net.InetSocketAddress; -import java.net.Socket; import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; 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.CountDownLatch; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -49,170 +37,59 @@ * Component that responsible for container removal and automatic cleanup of dead containers at JVM shutdown. */ @Slf4j -public final class ResourceReaper { +public class ResourceReaper { private static final Logger LOGGER = LoggerFactory.getLogger(ResourceReaper.class); - private static final List>> DEATH_NOTE = new ArrayList<>(); - private static final RateLimiter RYUK_ACK_RATE_LIMITER = RateLimiterBuilder - .newBuilder() - .withRate(4, TimeUnit.SECONDS) - .withConstantThroughput() - .build(); + 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 Set registeredNetworks = Sets.newConcurrentHashSet(); - private Set registeredImages = Sets.newConcurrentHashSet(); - private AtomicBoolean hookIsSet = new AtomicBoolean(false); - private ResourceReaper() { - dockerClient = DockerClientFactory.instance().client(); - } + private Set registeredNetworks = Sets.newConcurrentHashSet(); + private Set registeredImages = Sets.newConcurrentHashSet(); - /** - * - * @deprecated internal API - */ - @Deprecated - public static String start(String hostIpAddress, DockerClient client) { - return start(client); - } + private AtomicBoolean hookIsSet = new AtomicBoolean(false); /** - * - * @deprecated internal API + * Internal constructor to avoid custom implementations */ - @Deprecated - @SneakyThrows(InterruptedException.class) - public static String start(DockerClient client) { - String ryukImage = ImageNameSubstitutor.instance() - .apply(DockerImageName.parse("testcontainers/ryuk:0.3.1")) - .asCanonicalNameString(); - DockerClientFactory.instance().checkAndPullImage(client, ryukImage); - - List binds = new ArrayList<>(); - binds.add(new Bind(DockerClientFactory.instance().getRemoteDockerUnixSocketPath(), new Volume("/var/run/docker.sock"))); - - String ryukContainerId = client.createContainerCmd(ryukImage) - .withHostConfig(new HostConfig().withAutoRemove(true)) - .withExposedPorts(new ExposedPort(8080)) - .withPublishAllPorts(true) - .withName("testcontainers-ryuk-" + DockerClientFactory.SESSION_ID) - .withLabels(Collections.singletonMap(DockerClientFactory.TESTCONTAINERS_LABEL, "true")) - .withBinds(binds) - .withPrivileged(TestcontainersConfiguration.getInstance().isRyukPrivileged()) - .exec() - .getId(); - - client.startContainerCmd(ryukContainerId).exec(); - - StringBuilder ryukLog = new StringBuilder(); - - ResultCallback.Adapter logCallback = client.logContainerCmd(ryukContainerId) - .withSince(0) - .withFollowStream(true) - .withStdOut(true) - .withStdErr(true) - .exec(new ResultCallback.Adapter() { - @Override - public void onNext(Frame frame) { - ryukLog.append(new String(frame.getPayload(), StandardCharsets.UTF_8)); - } - }); - - ContainerState containerState = new ContainerState() { - - final InspectContainerResponse inspectedContainer = client.inspectContainerCmd(ryukContainerId).exec(); - - @Override - public List getExposedPorts() { - return Stream.of(getContainerInfo().getConfig().getExposedPorts()) - .map(ExposedPort::getPort) - .collect(Collectors.toList()); - } - - @Override - public InspectContainerResponse getContainerInfo() { - return inspectedContainer; - } - }; + ResourceReaper() {} - CountDownLatch ryukScheduledLatch = new CountDownLatch(1); - - synchronized (DEATH_NOTE) { - DEATH_NOTE.add( - DockerClientFactory.DEFAULT_LABELS.entrySet().stream() - .>map(it -> new SimpleEntry<>("label", it.getKey() + "=" + it.getValue())) - .collect(Collectors.toList()) - ); - } - - String host = containerState.getHost(); - Integer ryukPort = containerState.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); - FilterRegistry registry = new FilterRegistry(clientSocket.getInputStream(), clientSocket.getOutputStream()); - - synchronized (DEATH_NOTE) { - while (true) { - if (DEATH_NOTE.size() <= index) { - try { - DEATH_NOTE.wait(1_000); - continue; - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - } - List> filters = 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(); - try { - // 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{}", ryukLog); - throw new IllegalStateException(String.format("Could not connect to Ryuk at %s:%s", host, ryukPort)); - } - } finally { - try { - logCallback.close(); - } catch (IOException ignored) { - } - } - - return ryukContainerId; - } - - 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; @@ -220,9 +97,11 @@ 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); } @@ -231,7 +110,9 @@ public synchronized void performCleanup() { * 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); @@ -239,12 +120,29 @@ public void registerFilterForCleanup(List> filter) { } } + /** + * 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()) + ); + } + /** * Register a container to be cleaned up, either on explicit call to stopAndRemoveContainer, or at JVM shutdown. * * @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); @@ -254,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); } @@ -266,14 +166,16 @@ 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(); @@ -282,9 +184,11 @@ private void stopContainer(String containerId, String imageName) { LOGGER.trace("Was going to stop container but it apparently no longer exists: {}", containerId); return; } 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: {}", + 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()); + Throwables.getRootCause(e).getMessage() + ); return; } @@ -294,9 +198,11 @@ private void stopContainer(String containerId, String imageName) { dockerClient.killContainerCmd(containerId).exec(); LOGGER.trace("Stopped container: {}", 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: {}", + 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()); + Throwables.getRootCause(e).getMessage() + ); } } @@ -312,9 +218,11 @@ private void stopContainer(String containerId, String imageName) { 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: {}", + 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()); + Throwables.getRootCause(e).getMessage() + ); } } @@ -322,45 +230,22 @@ private void stopContainer(String containerId, String imageName) { * Register a network to be cleaned up at JVM shutdown. * * @param id the ID of the network + * @deprecated no longer supported API */ + @Deprecated public void registerNetworkIdForCleanup(String id) { setHook(); registeredNetworks.add(id); } - /** - * @param networkName the name of the network - * @deprecated see {@link ResourceReaper#registerNetworkIdForCleanup(String)} - */ - @Deprecated - public void registerNetworkForCleanup(String networkName) { - try { - // Try to find the network by name, so that we can register its ID for later deletion - dockerClient.listNetworksCmd() - .withNameFilter(networkName) - .exec() - .forEach(network -> registerNetworkIdForCleanup(network.getId())); - } catch (Exception e) { - LOGGER.trace("Error encountered when looking up network (name: {})", networkName); - } - } - /** * Removes a network by ID. * @param id - */ - public void removeNetworkById(String id) { - removeNetwork(id); - } - - /** - * Removes a network by ID. - * @param identifier - * @deprecated see {@link ResourceReaper#removeNetworkById(String)} + * @deprecated use {@link DockerClient} directly */ @Deprecated - public void removeNetworks(String identifier) { - removeNetworkById(identifier); + public void removeNetworkById(String id) { + removeNetwork(id); } private void removeNetwork(String id) { @@ -371,7 +256,10 @@ private void removeNetwork(String id) { // 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); + LOGGER.trace( + "Error encountered when looking up network for removal (name: {}) - it may not have been removed", + id + ); return; } @@ -383,7 +271,10 @@ private void removeNetwork(String id) { 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()); + LOGGER.trace( + "Error encountered removing network (name: {}) - it may not have been removed", + network.getName() + ); } } } finally { @@ -391,14 +282,26 @@ private void removeNetwork(String id) { } } + /** + * @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); @@ -413,19 +316,47 @@ private void removeImage(String dockerImageName) { } } - private void setHook() { + 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)); + 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) { @@ -437,14 +368,17 @@ static class FilterRegistry { * Registers the given filters with Ryuk * * @param filters the filter to register - * @return true if the filters have been registered successfuly, false otherwise + * @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() + String query = filters + .stream() .map(it -> { try { - return URLEncoder.encode(it.getKey(), "UTF-8") + "=" + URLEncoder.encode(it.getValue(), "UTF-8"); + return ( + URLEncoder.encode(it.getKey(), "UTF-8") + "=" + URLEncoder.encode(it.getValue(), "UTF-8") + ); } catch (UnsupportedEncodingException e) { throw new RuntimeException(e); } @@ -466,6 +400,5 @@ private static boolean waitForAcknowledgment(BufferedReader in) throws IOExcepti } 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 3b4d9f8f70c..5c80b82ba9c 100644 --- a/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java +++ b/core/src/main/java/org/testcontainers/utility/TestcontainersConfiguration.java @@ -8,7 +8,7 @@ import lombok.SneakyThrows; import lombok.Synchronized; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang.exception.ExceptionUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -50,17 +50,29 @@ public class TestcontainersConfiguration { 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 ImmutableMap CONTAINER_MAPPING = ImmutableMap.builder() + 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") @@ -71,12 +83,13 @@ public class TestcontainersConfiguration { .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(); - @SuppressWarnings({"ConstantConditions", "unchecked", "rawtypes"}) + @SuppressWarnings({ "ConstantConditions", "unchecked", "rawtypes" }) @VisibleForTesting static AtomicReference getInstanceField() { // Lazy Getter from Lombok changes the field's type to AtomicReference @@ -84,10 +97,16 @@ static AtomicReference getInstanceField() { } private final Properties userProperties; + private final Properties classpathProperties; + private final Map environment; - TestcontainersConfiguration(Properties userProperties, Properties classpathProperties, final Map environment) { + TestcontainersConfiguration( + Properties userProperties, + Properties classpathProperties, + final Map environment + ) { this.userProperties = userProperties; this.classpathProperties = classpathProperties; this.environment = environment; @@ -119,8 +138,7 @@ public String getTinyImage() { } public boolean isRyukPrivileged() { - return Boolean - .parseBoolean(getEnvVarOrProperty("ryuk.container.privileged", "false")); + return Boolean.parseBoolean(getEnvVarOrProperty("ryuk.container.privileged", "true")); } @Deprecated @@ -144,7 +162,7 @@ public String getKafkaImage() { @Deprecated public String getOracleImage() { - return getEnvVarOrProperty("oracle.container.image", null); + return getImage(ORACLE_IMAGE).asCanonicalNameString(); } @Deprecated @@ -163,36 +181,66 @@ public boolean isDisableChecks() { @UnstableAPI public boolean environmentSupportsReuse() { - // specifically not supported as an environment variable or classpath property 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", "okhttp"); + 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) { + private String getConfigurable( + @NotNull final String propertyName, + @Nullable final String defaultValue, + Properties... propertiesSources + ) { String envVarName = propertyName.replaceAll("\\.", "_").toUpperCase(); - if (!envVarName.startsWith("TESTCONTAINERS_")) { + if (!envVarName.startsWith("TESTCONTAINERS_") && !envVarName.startsWith("DOCKER_")) { envVarName = "TESTCONTAINERS_" + envVarName; } if (environment.containsKey(envVarName)) { - return environment.get(envVarName); + String value = environment.get(envVarName); + if (!value.isEmpty()) { + return value; + } } for (final Properties properties : propertiesSources) { @@ -208,6 +256,10 @@ private String getConfigurable(@NotNull final String propertyName, @Nullable fin * 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 @@ -220,6 +272,10 @@ public String getEnvVarOrProperty(@NotNull final String propertyName, @Nullable /** * 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 @@ -230,15 +286,16 @@ public String getEnvVarOrUserProperty(@NotNull final String propertyName, @Nulla } /** - * Gets a configured setting from a the user's configuration file. - * The configuration file will be the .testcontainers.properties file in the user's home directory. + * 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 getConfigurable(propertyName, defaultValue); + return this.userProperties.get(propertyName) != null + ? (String) this.userProperties.get(propertyName) + : defaultValue; } /** @@ -250,11 +307,15 @@ public String getUserProperty(@NotNull final String propertyName, @Nullable fina */ @Deprecated public Properties getProperties() { - return Stream.of(userProperties, classpathProperties) - .reduce(new Properties(), (a, b) -> { - a.putAll(b); - return a; - }); + return Stream + .of(userProperties, classpathProperties) + .reduce( + new Properties(), + (a, b) -> { + a.putAll(b); + return a; + } + ); } @Deprecated @@ -289,15 +350,20 @@ public boolean updateUserConfig(@NonNull String prop, @NonNull String value) { private static TestcontainersConfiguration loadConfiguration() { return new TestcontainersConfiguration( readProperties(USER_CONFIG_FILE.toURI().toURL()), - ClasspathScanner.scanFor(PROPERTIES_FILE_NAME) + 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()); + .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) { @@ -306,9 +372,17 @@ private static Properties readProperties(URL url) { try (InputStream inputStream = url.openStream()) { properties.load(inputStream); } catch (FileNotFoundException e) { - log.warn("Attempted to read Testcontainers configuration file at {} but the file was not found. Exception message: {}", url, ExceptionUtils.getRootCauseMessage(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.warn("Attempted to read Testcontainers configuration file at {} but could it not be loaded. Exception message: {}", url, ExceptionUtils.getRootCauseMessage(e)); + log.debug( + "Attempted to read Testcontainers configuration file at {} but could it not be loaded. Exception message: {}", + url, + ExceptionUtils.getRootCauseMessage(e) + ); } return properties; } @@ -320,14 +394,14 @@ private DockerImageName getImage(final String defaultValue) { 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 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 index 6b2cef907e1..a8138f9dda0 100644 --- a/core/src/main/java/org/testcontainers/utility/ThrowingFunction.java +++ b/core/src/main/java/org/testcontainers/utility/ThrowingFunction.java @@ -1,6 +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 index 4b1da407440..707c2b91820 100644 --- a/core/src/main/java/org/testcontainers/utility/Versioning.java +++ b/core/src/main/java/org/testcontainers/utility/Versioning.java @@ -45,8 +45,11 @@ public int 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) { @@ -71,15 +74,31 @@ public String toString() { @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); } 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/org.testcontainers.dockerclient.DockerClientProviderStrategy b/core/src/main/resources/META-INF/services/org.testcontainers.dockerclient.DockerClientProviderStrategy index d9cbad0ebff..cae8bf53041 100644 --- a/core/src/main/resources/META-INF/services/org.testcontainers.dockerclient.DockerClientProviderStrategy +++ b/core/src/main/resources/META-INF/services/org.testcontainers.dockerclient.DockerClientProviderStrategy @@ -1,5 +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.utility.ImageNameSubstitutor b/core/src/main/resources/META-INF/services/org.testcontainers.utility.ImageNameSubstitutor deleted file mode 100644 index 069dc7ee710..00000000000 --- a/core/src/main/resources/META-INF/services/org.testcontainers.utility.ImageNameSubstitutor +++ /dev/null @@ -1 +0,0 @@ -org.testcontainers.utility.DefaultImageNameSubstitutor diff --git a/core/src/test/java/alt/testcontainers/images/OutOfPackageImagePullPolicyTest.java b/core/src/test/java/alt/testcontainers/images/OutOfPackageImagePullPolicyTest.java index e4197c7c1f6..5b13d172386 100644 --- a/core/src/test/java/alt/testcontainers/images/OutOfPackageImagePullPolicyTest.java +++ b/core/src/test/java/alt/testcontainers/images/OutOfPackageImagePullPolicyTest.java @@ -1,29 +1,30 @@ package alt.testcontainers.images; -import org.junit.Test; +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; -import static org.testcontainers.TestImages.TINY_IMAGE; +class OutOfPackageImagePullPolicyTest { -public class OutOfPackageImagePullPolicyTest { @Test - public void shouldSupportCustomPoliciesOutOfTestContainersPackage() { + void shouldSupportCustomPoliciesOutOfTestcontainersPackage() { try ( - GenericContainer container = new GenericContainer<>(TINY_IMAGE) - .withImagePullPolicy(new AbstractImagePullPolicy() { - @Override - protected boolean shouldPullCached(DockerImageName imageName, ImageData localImageData) { - return false; + 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 index f08614c8cdc..01f4a1c5475 100644 --- a/core/src/test/java/org/testcontainers/DaemonTest.java +++ b/core/src/test/java/org/testcontainers/DaemonTest.java @@ -1,7 +1,6 @@ package org.testcontainers; -import org.junit.Test; -import org.rnorth.visibleassertions.VisibleAssertions; +import org.junit.jupiter.api.Test; import org.testcontainers.containers.GenericContainer; import java.io.File; @@ -9,13 +8,13 @@ import java.util.Set; import java.util.stream.Collectors; -import static org.junit.Assert.assertEquals; -import static org.testcontainers.TestImages.TINY_IMAGE; +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 */ -public class DaemonTest { +class DaemonTest { public static void main(String[] args) { Thread mainThread = Thread.currentThread(); @@ -23,7 +22,7 @@ public static void main(String[] args) { GenericContainer genericContainer = null; try { - genericContainer = new GenericContainer<>(TINY_IMAGE).withCommand("top"); + genericContainer = new GenericContainer<>(TestImages.TINY_IMAGE).withCommand("top"); genericContainer.start(); Set threads = new HashSet<>(Thread.getAllStackTraces().keySet()); @@ -31,14 +30,13 @@ public static void main(String[] args) { Set nonDaemonThreads = threads.stream().filter(it -> !it.isDaemon()).collect(Collectors.toSet()); - if (nonDaemonThreads.isEmpty()) { - VisibleAssertions.pass("All threads marked as daemon"); - } else { - String nonDaemonThreadNames = nonDaemonThreads.stream() + if (!nonDaemonThreads.isEmpty()) { + String nonDaemonThreadNames = nonDaemonThreads + .stream() .map(Thread::getName) .collect(Collectors.joining("\n", "\n", "")); - VisibleAssertions.fail("Expected all threads to be daemons but the following are not:\n" + nonDaemonThreadNames); + fail("Expected all threads to be daemons but the following are not:\n" + nonDaemonThreadNames); } } finally { if (genericContainer != null) { @@ -48,7 +46,7 @@ public static void main(String[] args) { } @Test - public void testThatAllThreadsAreDaemons() throws Exception { + void testThatAllThreadsAreDaemons() throws Exception { ProcessBuilder processBuilder = new ProcessBuilder( new File(System.getProperty("java.home")).toPath().resolve("bin").resolve("java").toString(), "-ea", @@ -57,6 +55,6 @@ public void testThatAllThreadsAreDaemons() throws Exception { DaemonTest.class.getCanonicalName() ); - assertEquals(0, processBuilder.inheritIO().start().waitFor()); + 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 index d484cc92eaf..56c68d4fd5d 100644 --- a/core/src/test/java/org/testcontainers/DockerClientFactoryTest.java +++ b/core/src/test/java/org/testcontainers/DockerClientFactoryTest.java @@ -1,91 +1,60 @@ 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; -import static org.testcontainers.TestImages.TINY_IMAGE; - -import com.github.dockerjava.api.DockerClient; -import com.github.dockerjava.api.exception.DockerException; -import com.github.dockerjava.api.exception.NotFoundException; -import org.junit.Rule; -import org.junit.Test; -import org.mockito.Mockito; -import org.rnorth.visibleassertions.VisibleAssertions; -import org.testcontainers.DockerClientFactory.DiskSpaceUsage; -import org.testcontainers.dockerclient.LogToStringContainerCallback; -import org.testcontainers.utility.MockTestcontainersConfigurationRule; -import org.testcontainers.utility.TestcontainersConfiguration; /** * Test for {@link DockerClientFactory}. */ -public class DockerClientFactoryTest { - - @Rule - public MockTestcontainersConfigurationRule configurationMock = new MockTestcontainersConfigurationRule(); +@ExtendWith(MockTestcontainersConfigurationExtension.class) +class DockerClientFactoryTest { @Test - public void runCommandInsideDockerShouldNotFailIfImageDoesNotExistsLocally() { + void runCommandInsideDockerShouldNotFailIfImageDoesNotExistsLocally() { + try (DockerRegistryContainer registryContainer = new DockerRegistryContainer()) { + registryContainer.start(); + DockerImageName imageName = registryContainer.createImage(); - final DockerClientFactory dockFactory = DockerClientFactory.instance(); - try { - //remove tiny image, so it will be pulled during next command run - dockFactory.client() - .removeImageCmd(TINY_IMAGE.asCanonicalNameString()) - .withForce(true).exec(); - } catch (NotFoundException ignored) { - // Do not fail if it's not pulled yet - } + DockerClientFactory dockFactory = DockerClientFactory.instance(); - dockFactory.runInsideDocker( + dockFactory.runInsideDocker( + imageName, cmd -> cmd.withCmd("sh", "-c", "echo 'SUCCESS'"), - (client, id) -> - client.logContainerCmd(id) - .withStdOut(true) - .exec(new LogToStringContainerCallback()) - .toString() - ); - } - - @Test - public void shouldHandleBigDiskSize() throws Exception { - String dfOutput = "/dev/disk1 2982480572 1491240286 2982480572 31% /"; - DiskSpaceUsage usage = DockerClientFactory.instance().parseAvailableDiskSpace(dfOutput); - - VisibleAssertions.assertEquals("Available MB is correct", 2982480572L / 1024L, usage.availableMB.orElse(0L)); - VisibleAssertions.assertEquals("Available percentage is correct", 31, usage.usedPercent.orElse(0)); + (client, id) -> { + return client + .logContainerCmd(id) + .withStdOut(true) + .exec(new LogToStringContainerCallback()) + .toString(); + } + ); + } } @Test - public void dockerHostIpAddress() { + void dockerHostIpAddress() { DockerClientFactory instance = new DockerClientFactory(); instance.strategy = null; assertThat(instance.dockerHostIpAddress()).isNotNull(); } @Test - public void failedChecksFailFast() { - Mockito.doReturn(false).when(TestcontainersConfiguration.getInstance()).isDisableChecks(); - - // Make sure that Ryuk is started - assertThat(DockerClientFactory.instance().client()).isNotNull(); - - DockerClientFactory instance = new DockerClientFactory(); - DockerClient dockerClient = instance.dockerClient; + void failedChecksFailFast() { + DockerClientFactory instance = DockerClientFactory.instance(); + assertThat(instance.client()).isNotNull(); assertThat(instance.cachedClientFailure).isNull(); try { - // Remove cached client to force the initialization logic - instance.dockerClient = null; - - // Ryuk should fail to start twice due to the name conflict (equal to the session id) - assertThatThrownBy(instance::client).isInstanceOf(DockerException.class); - RuntimeException failure = new IllegalStateException("Boom!"); instance.cachedClientFailure = failure; // Fail fast assertThatThrownBy(instance::client).isEqualTo(failure); } finally { - instance.dockerClient = dockerClient; 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 index 7052f7e8f9d..0d052f8e963 100644 --- a/core/src/test/java/org/testcontainers/TestImages.java +++ b/core/src/test/java/org/testcontainers/TestImages.java @@ -3,10 +3,15 @@ import org.testcontainers.utility.DockerImageName; public interface TestImages { - DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:3.0.2"); - DockerImageName RABBITMQ_IMAGE = DockerImageName.parse("rabbitmq:3.5.3"); - DockerImageName MONGODB_IMAGE = DockerImageName.parse("mongo:3.1.5"); - DockerImageName ALPINE_IMAGE = DockerImageName.parse("alpine:3.2"); + 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.5"); + + 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 index 8927972e642..52d8d003003 100644 --- a/core/src/test/java/org/testcontainers/containers/DockerComposeContainerWithServicesTest.java +++ b/core/src/test/java/org/testcontainers/containers/DockerComposeContainerWithServicesTest.java @@ -1,24 +1,40 @@ package org.testcontainers.containers; -import org.junit.Test; +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.rnorth.visibleassertions.VisibleAssertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +class DockerComposeContainerWithServicesTest { -public class DockerComposeContainerWithServicesTest { + public static final File SIMPLE_COMPOSE_FILE = new File( + "src/test/resources/compose-scaling-multiple-containers.yml" + ); - 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_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 - public void testDesiredSubsetOfServicesAreStarted() { + void testDesiredSubsetOfServicesAreStarted() { try ( - DockerComposeContainer compose = new DockerComposeContainer<>(SIMPLE_COMPOSE_FILE) + DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + SIMPLE_COMPOSE_FILE + ) .withServices("redis") ) { compose.start(); @@ -28,9 +44,12 @@ public void testDesiredSubsetOfServicesAreStarted() { } @Test - public void testDesiredSubsetOfScaledServicesAreStarted() { + void testDesiredSubsetOfScaledServicesAreStarted() { try ( - DockerComposeContainer compose = new DockerComposeContainer<>(SIMPLE_COMPOSE_FILE) + DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + SIMPLE_COMPOSE_FILE + ) .withScaledService("redis", 2) ) { compose.start(); @@ -40,9 +59,12 @@ public void testDesiredSubsetOfScaledServicesAreStarted() { } @Test - public void testDesiredSubsetOfSpecifiedAndScaledServicesAreStarted() { + void testDesiredSubsetOfSpecifiedAndScaledServicesAreStarted() { try ( - DockerComposeContainer compose = new DockerComposeContainer<>(SIMPLE_COMPOSE_FILE) + DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + SIMPLE_COMPOSE_FILE + ) .withServices("redis") .withScaledService("redis", 2) ) { @@ -53,9 +75,12 @@ public void testDesiredSubsetOfSpecifiedAndScaledServicesAreStarted() { } @Test - public void testDesiredSubsetOfSpecifiedOrScaledServicesAreStarted() { + void testDesiredSubsetOfSpecifiedOrScaledServicesAreStarted() { try ( - DockerComposeContainer compose = new DockerComposeContainer<>(SIMPLE_COMPOSE_FILE) + DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + SIMPLE_COMPOSE_FILE + ) .withServices("other") .withScaledService("redis", 2) ) { @@ -66,9 +91,12 @@ public void testDesiredSubsetOfSpecifiedOrScaledServicesAreStarted() { } @Test - public void testAllServicesAreStartedIfNotSpecified() { + void testAllServicesAreStartedIfNotSpecified() { try ( - DockerComposeContainer compose = new DockerComposeContainer<>(SIMPLE_COMPOSE_FILE) + DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + SIMPLE_COMPOSE_FILE + ) ) { compose.start(); @@ -77,9 +105,12 @@ public void testAllServicesAreStartedIfNotSpecified() { } @Test - public void testScaleInComposeFileIsRespected() { + void testScaleInComposeFileIsRespected() { try ( - DockerComposeContainer compose = new DockerComposeContainer<>(COMPOSE_FILE_WITH_INLINE_SCALE) + DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:debian-1.29.2"), + COMPOSE_FILE_WITH_INLINE_SCALE + ) ) { compose.start(); @@ -88,20 +119,80 @@ public void testScaleInComposeFileIsRespected() { } } + @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() + final List containerNames = compose + .listChildContainers() + .stream() .flatMap(container -> Stream.of(container.getNames())) .collect(Collectors.toList()); - assertEquals("number of running services of docker-compose is the same as length of listOfServices", - names.length, containerNames.size()); + 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(); + final long matches = containerNames.stream().filter(foundName -> foundName.endsWith(expectedName)).count(); - assertEquals("container with name starting '" + expectedName + "' should be running", 1L, matches); + 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 index 9e9eb27b509..e3bd499cc45 100644 --- a/core/src/test/java/org/testcontainers/containers/DockerComposeOverridesTest.java +++ b/core/src/test/java/org/testcontainers/containers/DockerComposeOverridesTest.java @@ -2,13 +2,11 @@ import com.google.common.util.concurrent.Uninterruptibles; import org.assertj.core.api.Assumptions; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.junit.runners.Parameterized.Parameters; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import org.rnorth.ducttape.unreliables.Unreliables; -import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.CommandLine; +import org.testcontainers.utility.DockerImageName; import java.io.BufferedReader; import java.io.File; @@ -17,77 +15,83 @@ import java.util.Arrays; import java.util.concurrent.TimeUnit; -import static org.rnorth.visibleassertions.VisibleAssertions.info; -import static org.rnorth.visibleassertions.VisibleAssertions.pass; - -@RunWith(Parameterized.class) -public class DockerComposeOverridesTest { +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 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"; - private final boolean localMode; - private final String expectedEnvVar; - private final File[] composeFiles; + private static final int SERVICE_PORT = 3000; - public DockerComposeOverridesTest(boolean localMode, String expectedEnvVar, File... composeFiles) { - this.localMode = localMode; - this.expectedEnvVar = expectedEnvVar; - this.composeFiles = composeFiles; - } + private static final String SERVICE_NAME = "alpine_1"; - @Parameters(name = "{index}: local[{0}], composeFiles[{2}], expectedEnvVar[{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}}, - }); + 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 } }, + } + ); } - @Before - public void setUp() { + @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(LocalDockerCompose.executableExists()) + 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"); - @Test - public void test() { - try (DockerComposeContainer compose = - new DockerComposeContainer(composeFiles) - .withLocalCompose(localMode) - .withExposedService(SERVICE_NAME, SERVICE_PORT)) { + 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(); + compose.start(); - BufferedReader br = Unreliables.retryUntilSuccess(10, TimeUnit.SECONDS, () -> { + 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)); + 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, () -> { + Unreliables.retryUntilTrue( + 10, + TimeUnit.SECONDS, + () -> { while (br.ready()) { String line = br.readLine(); if (line.contains(expectedEnvVar)) { - 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; - }); - } + } + ); + 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 index d17b0709dfa..50bd17d0f0d 100644 --- a/core/src/test/java/org/testcontainers/containers/ExposedHostTest.java +++ b/core/src/test/java/org/testcontainers/containers/ExposedHostTest.java @@ -3,70 +3,143 @@ import com.google.common.collect.ImmutableMap; import com.sun.net.httpserver.HttpServer; import lombok.SneakyThrows; -import org.junit.AfterClass; -import org.junit.BeforeClass; -import org.junit.Test; +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.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.testcontainers.TestImages.TINY_IMAGE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; -public class ExposedHostTest { +class ExposedHostTest { private static HttpServer server; - @BeforeClass + @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.createContext( + "/", + exchange -> { + byte[] content = "Hello World!".getBytes(); + exchange.sendResponseHeaders(200, content.length); + try (OutputStream responseBody = exchange.getResponseBody()) { + responseBody.write(content); + responseBody.flush(); + } } - }); - + ); server.start(); - Testcontainers.exposeHostPorts(server.getAddress().getPort()); - - Testcontainers.exposeHostPorts(ImmutableMap.of(server.getAddress().getPort(), 80)); - Testcontainers.exposeHostPorts(ImmutableMap.of(server.getAddress().getPort(), 81)); } - @AfterClass - public static void tearDownClass() throws Exception { + @AfterAll + public static void tearDownClass() { server.stop(0); } + @AfterEach + public void tearDown() { + PortForwardingContainer.INSTANCE.reset(); + } + @Test - public void testExposedHost() throws Exception { - assertResponse(new GenericContainer<>(TINY_IMAGE) - .withCommand("top"), - server.getAddress().getPort()); + 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 - public void testExposedHostWithNetwork() throws Exception { + void testExposedHostWithNetwork() { + Testcontainers.exposeHostPorts(server.getAddress().getPort()); try (Network network = Network.newNetwork()) { - assertResponse(new GenericContainer<>(TINY_IMAGE) - .withNetwork(network) - .withCommand("top"), - server.getAddress().getPort()); + assertResponse( + new GenericContainer<>(tinyContainerDef()).withNetwork(network), + server.getAddress().getPort() + ); } } @Test - public void testExposedHostPortOnFixedInternalPorts() throws Exception { - assertResponse(new GenericContainer<>(TINY_IMAGE) - .withCommand("top"), - 80); - assertResponse(new GenericContainer<>(TINY_IMAGE) - .withCommand("top"), - 81); + 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 @@ -74,11 +147,56 @@ protected void assertResponse(GenericContainer container, int port) { try { container.start(); - String response = container.execInContainer("wget", "-O", "-", "http://host.testcontainers.internal:" + port).getStdout(); + String response = container + .execInContainer("wget", "-O", "-", "http://host.testcontainers.internal:" + port) + .getStdout(); - assertEquals("received response", "Hello World!", response); + 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/FailureDetectingExternalResourceTest.java b/core/src/test/java/org/testcontainers/containers/FailureDetectingExternalResourceTest.java deleted file mode 100644 index 63993bc24ff..00000000000 --- a/core/src/test/java/org/testcontainers/containers/FailureDetectingExternalResourceTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.testcontainers.containers; - -import org.junit.Test; -import org.junit.runner.Description; -import org.junit.runners.model.Statement; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; - -public class FailureDetectingExternalResourceTest { - - @Test - public void finishedIsCalledForCleanupIfStartingThrows() throws Throwable { - FailureDetectingExternalResource res = spy(FailureDetectingExternalResource.class); - Statement stmt = res.apply(mock(Statement.class), Description.EMPTY); - doThrow(new RuntimeException()).when(res).starting(any()); - try { - stmt.evaluate(); - } catch (Throwable t) { - // ignore - } - verify(res).starting(any()); - verify(res).finished(any()); - } - -} diff --git a/core/src/test/java/org/testcontainers/containers/GenericContainerTest.java b/core/src/test/java/org/testcontainers/containers/GenericContainerTest.java index e059a48709f..37aac1b0af9 100644 --- a/core/src/test/java/org/testcontainers/containers/GenericContainerTest.java +++ b/core/src/test/java/org/testcontainers/containers/GenericContainerTest.java @@ -1,39 +1,65 @@ 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.Test; +import org.junit.jupiter.api.Test; import org.rnorth.ducttape.unreliables.Unreliables; -import org.testcontainers.TestImages; 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; -public class GenericContainerTest { +class GenericContainerTest { @Test - public void shouldReportOOMAfterWait() { + void shouldReportOOMAfterWait() { Info info = DockerClientFactory.instance().client().infoCmd().exec(); // Poor man's rootless Docker detection :D - Assumptions.assumeThat(info.getSecurityOptions()).doesNotContain("rootless"); + 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() + it + .getHostConfig() .withMemory(20 * FileUtils.ONE_MB) .withMemorySwappiness(0L) .withMemorySwap(0L) @@ -43,12 +69,13 @@ public void shouldReportOOMAfterWait() { .withCommand("sh", "-c", "A='0123456789'; for i in $(seq 0 32); do A=$A$A; done; sleep 10m") ) { assertThatThrownBy(container::start) - .hasStackTraceContaining("Container crashed with out-of-memory"); + .hasStackTraceContaining("Wait strategy failed. Container crashed with out-of-memory (OOMKilled)") + .hasStackTraceContaining("Nope!"); } } @Test - public void shouldReportErrorAfterWait() { + void shouldReportErrorAfterWait() { try ( GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) .withStartupCheckStrategy(new NoopStartupCheckStrategy()) @@ -56,10 +83,259 @@ public void shouldReportErrorAfterWait() { .withCommand("sh", "-c", "usleep 100; exit 123") ) { assertThatThrownBy(container::start) - .hasStackTraceContaining("Container exited with code 123"); + .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 @@ -78,18 +354,58 @@ static class WaitForExitedState extends AbstractWaitStrategy { @Override @SneakyThrows protected void waitUntilReady() { - Unreliables.retryUntilTrue(5, TimeUnit.SECONDS, () -> { - ContainerState state = waitStrategyTarget.getCurrentContainerInfo().getState(); + 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; + log.debug("Current state: {}", state); + if (!"exited".equalsIgnoreCase(state.getStatus())) { + Thread.sleep(100); + return false; + } + return predicate.test(state); } - 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 index a39f0552a5a..675658b353a 100644 --- a/core/src/test/java/org/testcontainers/containers/NetworkTest.java +++ b/core/src/test/java/org/testcontainers/containers/NetworkTest.java @@ -1,117 +1,112 @@ package org.testcontainers.containers; -import org.junit.Rule; -import org.junit.Test; -import org.junit.experimental.runners.Enclosed; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; import org.testcontainers.DockerClientFactory; import org.testcontainers.TestImages; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertNotEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertNotNull; -import static org.testcontainers.containers.Network.newNetwork; +import static org.assertj.core.api.Assertions.assertThat; -@RunWith(Enclosed.class) -public class NetworkTest { +class NetworkTest { - public static class WithRules { + @Nested + class WithRules { - @Rule - public Network network = newNetwork(); + public Network network = Network.newNetwork(); - @Rule 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"); + .withNetwork(network) + .withNetworkAliases("foo") + .withCommand("/bin/sh", "-c", "while true ; do printf 'HTTP/1.1 200 OK\\n\\nyay' | nc -l -p 8080; done"); - @Rule public GenericContainer bar = new GenericContainer<>(TestImages.TINY_IMAGE) - .withNetwork(network) - .withCommand("top"); + .withNetwork(network) + .withCommand("top"); - @Test - public void testNetworkSupport() throws Exception { + void testNetworkSupport() throws Exception { + foo.start(); + bar.start(); String response = bar.execInContainer("wget", "-O", "-", "http://foo:8080").getStdout(); - assertEquals("received response", "yay", response); + assertThat(response).as("received response").isEqualTo("yay"); } } - public static class WithoutRules { + @Nested + class WithoutRules { @Test - public void testNetworkSupport() throws Exception { + 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") + 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(); - assertEquals("received response", "yay", response); + assertThat(response).as("received response").isEqualTo("yay"); } // } } @Test - public void testBuilder() { - try ( - Network network = Network.builder() - .driver("macvlan") - .build() - ) { + void testBuilder() { + try (Network network = Network.builder().driver("macvlan").build()) { String id = network.getId(); - assertEquals( - "Flag is set", - "macvlan", - DockerClientFactory.instance().client().inspectNetworkCmd().withNetworkId(id).exec().getDriver() - ); + assertThat( + DockerClientFactory.instance().client().inspectNetworkCmd().withNetworkId(id).exec().getDriver() + ) + .as("Flag is set") + .isEqualTo("macvlan"); } } @Test - public void testModifiers() { + void testModifiers() { try ( - Network network = Network.builder() - .createNetworkCmdModifier(cmd -> cmd.withDriver("macvlan")) - .build() + Network network = Network.builder().createNetworkCmdModifier(cmd -> cmd.withDriver("macvlan")).build() ) { String id = network.getId(); - assertEquals( - "Flag is set", - "macvlan", - DockerClientFactory.instance().client().inspectNetworkCmd().withNetworkId(id).exec().getDriver() - ); + assertThat( + DockerClientFactory.instance().client().inspectNetworkCmd().withNetworkId(id).exec().getDriver() + ) + .as("Flag is set") + .isEqualTo("macvlan"); } } @Test - public void testReusability() { + void testReusability() { try (Network network = Network.newNetwork()) { String firstId = network.getId(); - assertNotNull( - "Network exists", - DockerClientFactory.instance().client().inspectNetworkCmd().withNetworkId(firstId).exec() - ); + assertThat(DockerClientFactory.instance().client().inspectNetworkCmd().withNetworkId(firstId).exec()) + .as("Network exists") + .isNotNull(); network.close(); - assertNotEquals( - "New network created", - firstId, - DockerClientFactory.instance().client().inspectNetworkCmd().withNetworkId(network.getId()).exec().getId() - ); + 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 index 9a8421086f1..36126df6a38 100644 --- a/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileValidationTest.java +++ b/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileValidationTest.java @@ -2,22 +2,29 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.Sets; -import org.assertj.core.api.Assertions; -import org.junit.Test; +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 java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; +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; -public class ParsedDockerComposeFileValidationTest { +class ParsedDockerComposeFileValidationTest { + + @TempDir + public Path temporaryFolder; @Test - public void shouldValidate() { + void shouldValidate() { File file = new File("src/test/resources/docker-compose-container-name-v1.yml"); - Assertions - .assertThatThrownBy(() -> { + assertThatThrownBy(() -> { new ParsedDockerComposeFile(file); }) .hasMessageContaining(file.getAbsolutePath()) @@ -25,78 +32,139 @@ public void shouldValidate() { } @Test - public void shouldRejectContainerNameV1() { - Assertions - .assertThatThrownBy(() -> { - new ParsedDockerComposeFile(ImmutableMap.of( - "redis", ImmutableMap.of("container_name", "redis") - )); + void shouldRejectContainerNameV1() { + assertThatThrownBy(() -> { + new ParsedDockerComposeFile(ImmutableMap.of("redis", ImmutableMap.of("container_name", "redis"))); }) .hasMessageContaining("'container_name' property set for service 'redis'"); } @Test - public void shouldRejectContainerNameV2() { - Assertions - .assertThatThrownBy(() -> { - new ParsedDockerComposeFile(ImmutableMap.of( - "version", "2", - "services", ImmutableMap.of( - "redis", ImmutableMap.of("container_name", "redis") + 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 - public void shouldIgnoreUnknownStructure() { + void shouldIgnoreUnknownStructure() { // Everything is a list - new ParsedDockerComposeFile(emptyMap()); + new ParsedDockerComposeFile(Collections.emptyMap()); // services is not a map but List - new ParsedDockerComposeFile(ImmutableMap.of( - "version", "2", - "services", emptyList() - )); + new ParsedDockerComposeFile(ImmutableMap.of("version", "2", "services", Collections.emptyList())); // services is not a collection - new ParsedDockerComposeFile(ImmutableMap.of( - "version", "2", - "services", true - )); + new ParsedDockerComposeFile(ImmutableMap.of("version", "2", "services", true)); // no services while version is defined - new ParsedDockerComposeFile(ImmutableMap.of( - "version", "9000" - )); + 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 - public void shouldObtainImageNamesV1() { + void shouldObtainImageNamesV1() { File file = new File("src/test/resources/docker-compose-imagename-parsing-v1.yml"); ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(file); - assertEquals("all defined images are found", Sets.newHashSet("redis", "mysql", "postgres"), parsedFile.getDependencyImageNames()); // redis, mysql from compose file, postgres from Dockerfile build + 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 - public void shouldObtainImageNamesV2() { + void shouldObtainImageNamesV2() { File file = new File("src/test/resources/docker-compose-imagename-parsing-v2.yml"); ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(file); - assertEquals("all defined images are found", Sets.newHashSet("redis", "mysql", "postgres"), parsedFile.getDependencyImageNames()); // redis, mysql from compose file, postgres from Dockerfile build + 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 - public void shouldObtainImageFromDockerfileBuild() { + void shouldObtainImageFromDockerfileBuild() { File file = new File("src/test/resources/docker-compose-imagename-parsing-dockerfile.yml"); ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(file); - assertEquals("all defined images are found", Sets.newHashSet("redis", "mysql", "alpine:3.2"), parsedFile.getDependencyImageNames()); // redis, mysql from compose file, alpine:3.2 from Dockerfile build + 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 - public void shouldObtainImageFromDockerfileBuildWithContext() { + void shouldObtainImageFromDockerfileBuildWithContext() { File file = new File("src/test/resources/docker-compose-imagename-parsing-dockerfile-with-context.yml"); ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(file); - assertEquals("all defined images are found", Sets.newHashSet("redis", "mysql", "alpine:3.2"), parsedFile.getDependencyImageNames()); // redis, mysql from compose file, alpine:3.2 from Dockerfile build + 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 index c212d04c33a..2c70a3f894f 100644 --- a/core/src/test/java/org/testcontainers/containers/ReusabilityUnitTests.java +++ b/core/src/test/java/org/testcontainers/containers/ReusabilityUnitTests.java @@ -14,21 +14,20 @@ import com.github.dockerjava.core.command.InspectContainerCmdImpl; import com.github.dockerjava.core.command.ListContainersCmdImpl; import com.github.dockerjava.core.command.StartContainerCmdImpl; -import lombok.RequiredArgsConstructor; -import lombok.experimental.FieldDefaults; -import org.junit.Rule; -import org.junit.Test; -import org.junit.experimental.runners.Enclosed; -import org.junit.runner.RunWith; -import org.junit.runners.BlockJUnit4ClassRunner; -import org.junit.runners.Parameterized; +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.rnorth.visibleassertions.VisibleAssertions; 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.MockTestcontainersConfigurationRule; +import org.testcontainers.utility.MockTestcontainersConfigurationExtension; import org.testcontainers.utility.MountableFile; import org.testcontainers.utility.TestcontainersConfiguration; @@ -36,12 +35,14 @@ 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; @@ -49,52 +50,44 @@ import static org.assertj.core.api.Assumptions.assumeThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; -import static org.testcontainers.TestImages.TINY_IMAGE; -@RunWith(Enclosed.class) -public class ReusabilityUnitTests { +class ReusabilityUnitTests { - @RunWith(Parameterized.class) - @RequiredArgsConstructor - @FieldDefaults(makeFinal = true) - public static class CanBeReusedTest { + @Nested + class CanBeReusedTest { - @Parameterized.Parameters(name = "{0}") public static Object[][] data() { return new Object[][] { - { "generic", new GenericContainer<>(TINY_IMAGE), true }, - { "anonymous generic", new GenericContainer(TINY_IMAGE) {}, true }, + { "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 }, }; } - String name; - - GenericContainer container; - - boolean reusable; - - @Test - public void shouldBeReusable() { + @ParameterizedTest(name = "{0}") + @MethodSource("data") + void shouldBeReusable(String name, GenericContainer container, boolean reusable) { if (reusable) { - VisibleAssertions.assertTrue("Is reusable", container.canBeReused()); + assertThat(container.canBeReused()).as("Is reusable").isTrue(); } else { - VisibleAssertions.assertFalse("Is not reusable", container.canBeReused()); + assertThat(container.canBeReused()).as("Is not reusable").isFalse(); } } static class CustomContainer extends GenericContainer { + CustomContainer() { - super(TINY_IMAGE); + super(TestImages.TINY_IMAGE); } } - static class CustomContainerWithContainerIsCreated extends GenericContainer { + static class CustomContainerWithContainerIsCreated + extends GenericContainer { CustomContainerWithContainerIsCreated() { - super(TINY_IMAGE); + super(TestImages.TINY_IMAGE); } @Override @@ -104,38 +97,38 @@ protected void containerIsCreated(String containerId) { } } - @RunWith(BlockJUnit4ClassRunner.class) - @FieldDefaults(makeFinal = true) - public static class HooksTest extends AbstractReusabilityTest { + @Nested + class HooksTest extends AbstractReusabilityTest { List script = new ArrayList<>(); - GenericContainer container = makeReusable(new GenericContainer(TINY_IMAGE) { - - @Override - protected boolean canBeReused() { - // Because we override "containerIsCreated" - return true; - } + 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 containerIsCreated(String containerId) { + script.add("containerIsCreated"); + } - @Override - protected void containerIsStarting(InspectContainerResponse containerInfo, boolean reused) { - script.add("containerIsStarting(reused=" + reused + ")"); - } + @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 + ")"); + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo, boolean reused) { + script.add("containerIsStarted(reused=" + reused + ")"); + } } - }); + ); @Test - public void shouldSetLabelsIfEnvironmentDoesNotSupportReuse() { + void shouldSetLabelsIfEnvironmentDoesNotSupportReuse() { Mockito.doReturn(false).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); String containerId = randomContainerId(); @@ -145,15 +138,16 @@ public void shouldSetLabelsIfEnvironmentDoesNotSupportReuse() { when(client.inspectContainerCmd(containerId)).then(inspectContainerAnswer()); container.start(); - assertThat(script).containsExactly( - "containerIsCreated", - "containerIsStarting(reused=false)", - "containerIsStarted(reused=false)" - ); + assertThat(script) + .containsExactly( + "containerIsCreated", + "containerIsStarting(reused=false)", + "containerIsStarted(reused=false)" + ); } @Test - public void shouldCallHookIfReused() { + void shouldCallHookIfReused() { Mockito.doReturn(true).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); String containerId = randomContainerId(); when(client.createContainerCmd(any())).then(createContainerAnswer(containerId)); @@ -162,14 +156,11 @@ public void shouldCallHookIfReused() { when(client.inspectContainerCmd(existingContainerId)).then(inspectContainerAnswer()); container.start(); - assertThat(script).containsExactly( - "containerIsStarting(reused=true)", - "containerIsStarted(reused=true)" - ); + assertThat(script).containsExactly("containerIsStarting(reused=true)", "containerIsStarted(reused=true)"); } @Test - public void shouldNotCallHookIfNotReused() { + void shouldNotCallHookIfNotReused() { String containerId = randomContainerId(); when(client.createContainerCmd(any())).then(createContainerAnswer(containerId)); when(client.listContainersCmd()).then(listContainersAnswer()); @@ -177,27 +168,29 @@ public void shouldNotCallHookIfNotReused() { when(client.inspectContainerCmd(containerId)).then(inspectContainerAnswer()); container.start(); - assertThat(script).containsExactly( - "containerIsCreated", - "containerIsStarting(reused=false)", - "containerIsStarted(reused=false)" - ); + assertThat(script) + .containsExactly( + "containerIsCreated", + "containerIsStarting(reused=false)", + "containerIsStarted(reused=false)" + ); } } - @RunWith(BlockJUnit4ClassRunner.class) - @FieldDefaults(makeFinal = true) - public static class HashTest extends AbstractReusabilityTest { + @Nested + class HashTest extends AbstractReusabilityTest { - protected GenericContainer container = makeReusable(new GenericContainer(TINY_IMAGE) { - @Override - public void copyFileToContainer(MountableFile mountableFile, String containerPath) { - // NOOP + protected GenericContainer container = makeReusable( + new GenericContainer(TestImages.TINY_IMAGE) { + @Override + public void copyFileToContainer(MountableFile mountableFile, String containerPath) { + // NOOP + } } - }); + ); @Test - public void shouldStartIfListReturnsEmpty() { + void shouldStartIfListReturnsEmpty() { String containerId = randomContainerId(); when(client.createContainerCmd(any())).then(createContainerAnswer(containerId)); when(client.listContainersCmd()).then(listContainersAnswer()); @@ -210,7 +203,7 @@ public void shouldStartIfListReturnsEmpty() { } @Test - public void shouldReuseIfListReturnsID() { + void shouldReuseIfListReturnsID() { Mockito.doReturn(true).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); String containerId = randomContainerId(); when(client.createContainerCmd(any())).then(createContainerAnswer(containerId)); @@ -225,7 +218,7 @@ public void shouldReuseIfListReturnsID() { } @Test - public void shouldSetLabelsIfEnvironmentDoesNotSupportReuse() { + void shouldSetLabelsIfEnvironmentDoesNotSupportReuse() { Mockito.doReturn(false).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); AtomicReference commandRef = new AtomicReference<>(); String containerId = randomContainerId(); @@ -244,7 +237,7 @@ public void shouldSetLabelsIfEnvironmentDoesNotSupportReuse() { } @Test - public void shouldSetCopiedFilesHashLabel() { + void shouldSetCopiedFilesHashLabel() { Mockito.doReturn(true).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); AtomicReference commandRef = new AtomicReference<>(); String containerId = randomContainerId(); @@ -256,12 +249,11 @@ public void shouldSetCopiedFilesHashLabel() { container.start(); assertThat(commandRef).isNotNull(); - assertThat(commandRef.get().getLabels()) - .containsKeys(GenericContainer.COPIED_FILES_HASH_LABEL); + assertThat(commandRef.get().getLabels()).containsKeys(GenericContainer.COPIED_FILES_HASH_LABEL); } @Test - public void shouldHashCopiedFiles() { + void shouldHashCopiedFiles() { Mockito.doReturn(true).when(TestcontainersConfiguration.getInstance()).environmentSupportsReuse(); AtomicReference commandRef = new AtomicReference<>(); String containerId = randomContainerId(); @@ -288,27 +280,87 @@ public void shouldHashCopiedFiles() { ); container.start(); - assertThat(commandRef.get().getLabels()).hasEntrySatisfying(GenericContainer.COPIED_FILES_HASH_LABEL, newHash -> { - assertThat(newHash).as("new hash").isNotEqualTo(oldHash); - }); + assertThat(commandRef.get().getLabels()) + .hasEntrySatisfying( + GenericContainer.COPIED_FILES_HASH_LABEL, + newHash -> { + assertThat(newHash).as("new hash").isNotEqualTo(oldHash); + } + ); } } - @RunWith(BlockJUnit4ClassRunner.class) - @FieldDefaults(makeFinal = true) - public static class CopyFilesHashTest { - GenericContainer container = new GenericContainer<>(TINY_IMAGE); + @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 - public void empty() { + void empty() { assertThat(container.hashCopiedFiles()).isNotNull(); } @Test - public void oneFile() { + void oneFile() { long emptyHash = container.hashCopiedFiles().getValue(); - container.withCopyFileToContainer( + strategy.withCopyFileToContainer( MountableFile.forClasspathResource("test_copy_to_container.txt"), "/foo/bar" ); @@ -317,24 +369,24 @@ public void oneFile() { } @Test - public void differentPath() { + void differentPath() { MountableFile mountableFile = MountableFile.forClasspathResource("test_copy_to_container.txt"); - container.withCopyFileToContainer(mountableFile, "/foo/bar"); + strategy.withCopyFileToContainer(mountableFile, "/foo/bar"); long hash1 = container.hashCopiedFiles().getValue(); - container.getCopyToFileContainerPathMap().clear(); + strategy.clear(); - container.withCopyFileToContainer(mountableFile, "/foo/baz"); + strategy.withCopyFileToContainer(mountableFile, "/foo/baz"); assertThat(container.hashCopiedFiles().getValue()).isNotEqualTo(hash1); } @Test - public void detectsChangesInFile() throws Exception { + void detectsChangesInFile() throws Exception { Path path = File.createTempFile("reusable_test", ".txt").toPath(); MountableFile mountableFile = MountableFile.forHostPath(path); - container.withCopyFileToContainer(mountableFile, "/foo/bar"); + strategy.withCopyFileToContainer(mountableFile, "/foo/bar"); long hash1 = container.hashCopiedFiles().getValue(); @@ -344,14 +396,14 @@ public void detectsChangesInFile() throws Exception { } @Test - public void multipleFiles() { - container.withCopyFileToContainer( + void multipleFiles() { + strategy.withCopyFileToContainer( MountableFile.forClasspathResource("test_copy_to_container.txt"), "/foo/bar" ); long hash1 = container.hashCopiedFiles().getValue(); - container.withCopyFileToContainer( + strategy.withCopyFileToContainer( MountableFile.forClasspathResource("mappable-resource/test-resource.txt"), "/foo/baz" ); @@ -360,22 +412,22 @@ public void multipleFiles() { } @Test - public void folder() throws Exception { + void folder() throws Exception { long emptyHash = container.hashCopiedFiles().getValue(); Path tempDirectory = Files.createTempDirectory("reusable_test"); MountableFile mountableFile = MountableFile.forHostPath(tempDirectory); - container.withCopyFileToContainer(mountableFile, "/foo/bar/"); + strategy.withCopyFileToContainer(mountableFile, "/foo/bar/"); assertThat(container.hashCopiedFiles().getValue()).isNotEqualTo(emptyHash); } @Test - public void changesInFolder() throws Exception { + void changesInFolder() throws Exception { Path tempDirectory = Files.createTempDirectory("reusable_test"); MountableFile mountableFile = MountableFile.forHostPath(tempDirectory); assertThat(new File(mountableFile.getResolvedPath())).isDirectory(); - container.withCopyFileToContainer(mountableFile, "/foo/bar/"); + strategy.withCopyFileToContainer(mountableFile, "/foo/bar/"); long hash1 = container.hashCopiedFiles().getValue(); @@ -390,15 +442,15 @@ public void changesInFolder() throws Exception { } @Test - public void folderAndFile() throws Exception { + void folderAndFile() throws Exception { Path tempDirectory = Files.createTempDirectory("reusable_test"); MountableFile mountableFile = MountableFile.forHostPath(tempDirectory); assertThat(new File(mountableFile.getResolvedPath())).isDirectory(); - container.withCopyFileToContainer(mountableFile, "/foo/bar/"); + strategy.withCopyFileToContainer(mountableFile, "/foo/bar/"); long hash1 = container.hashCopiedFiles().getValue(); - container.withCopyFileToContainer( + strategy.withCopyFileToContainer( MountableFile.forClasspathResource("test_copy_to_container.txt"), "/foo/baz" ); @@ -407,11 +459,11 @@ public void folderAndFile() throws Exception { } @Test - public void filePermissions() throws Exception { + void filePermissions() throws Exception { Path path = File.createTempFile("reusable_test", ".txt").toPath(); path.toFile().setExecutable(false); MountableFile mountableFile = MountableFile.forHostPath(path); - container.withCopyFileToContainer(mountableFile, "/foo/bar"); + strategy.withCopyFileToContainer(mountableFile, "/foo/bar"); long hash1 = container.hashCopiedFiles().getValue(); @@ -422,14 +474,14 @@ public void filePermissions() throws Exception { } @Test - public void folderPermissions() throws Exception { + 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(); - container.withCopyFileToContainer(mountableFile, "/foo/bar/"); + strategy.withCopyFileToContainer(mountableFile, "/foo/bar/"); long hash1 = container.hashCopiedFiles().getValue(); @@ -440,36 +492,34 @@ public void folderPermissions() throws Exception { } } - @FieldDefaults(makeFinal = true) - public static abstract class AbstractReusabilityTest { - - @Rule - public MockTestcontainersConfigurationRule configurationMock = new MockTestcontainersConfigurationRule(); + @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; + 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; + } } - - @Override - public StartupStatus checkStartupState(DockerClient dockerClient, String containerId) { - return StartupStatus.SUCCESSFUL; - } - }); - container.waitingFor(new AbstractWaitStrategy() { - @Override - protected void waitUntilReady() { - + ); + container.waitingFor( + new AbstractWaitStrategy() { + @Override + protected void waitUntilReady() {} } - }); + ); container.withReuse(true); return container; } @@ -481,12 +531,11 @@ protected String randomContainerId() { 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 ObjectMapper() + .convertValue( + Stream.of(ids).map(id -> Collections.singletonMap("Id", id)).collect(Collectors.toList()), + new TypeReference>() {} + ); }; return new ListContainersCmdImpl(exec); }; @@ -496,7 +545,10 @@ protected Answer createContainerAnswer(String containerId) { return createContainerAnswer(containerId, command -> {}); } - protected Answer createContainerAnswer(String containerId, Consumer cmdConsumer) { + protected Answer createContainerAnswer( + String containerId, + Consumer cmdConsumer + ) { return invocation -> { CreateContainerCmd.Exec exec = command -> { cmdConsumer.accept(command); @@ -520,7 +572,12 @@ protected Answer startContainerAnswer() { protected Answer inspectContainerAnswer() { return invocation -> { InspectContainerCmd.Exec exec = command -> { - return new InspectContainerResponse(); + 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 index 05ef7373293..f742b29890b 100644 --- a/core/src/test/java/org/testcontainers/containers/output/ContainerLogsTest.java +++ b/core/src/test/java/org/testcontainers/containers/output/ContainerLogsTest.java @@ -1,86 +1,82 @@ package org.testcontainers.containers.output; -import org.junit.Ignore; -import org.junit.Test; +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.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.testcontainers.TestImages.ALPINE_IMAGE; -import static org.testcontainers.containers.output.OutputFrame.OutputType.STDERR; -import static org.testcontainers.containers.output.OutputFrame.OutputType.STDOUT; +import static org.assertj.core.api.Assertions.assertThat; -public class ContainerLogsTest { +class ContainerLogsTest { @Test - @Ignore("fails due to the timing of the shell's decision to flush") - public void getLogsReturnsAllLogsToDate() { + @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(); - assertEquals("stdout and stderr are reflected in the returned logs", "stdout\nstderr", logs); + assertThat(logs).as("stdout and stderr are reflected in the returned logs").isEqualTo("stdout\nstderr"); } } @Test - public void getLogsContainsBothOutputTypes() { + void getLogsContainsBothOutputTypes() { try (GenericContainer container = shortLivedContainer()) { container.start(); // docsGetAllLogs { final String logs = container.getLogs(); // } - assertTrue("stdout is reflected in the returned logs", logs.contains("stdout")); - assertTrue("stderr is reflected in the returned logs", logs.contains("stderr")); + 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 - public void getLogsReturnsStdOutToDate() { + void getLogsReturnsStdOutToDate() { try (GenericContainer container = shortLivedContainer()) { container.start(); // docsGetStdOut { - final String logs = container.getLogs(STDOUT); + final String logs = container.getLogs(OutputFrame.OutputType.STDOUT); // } - assertTrue("stdout is reflected in the returned logs", logs.contains("stdout")); + assertThat(logs).as("stdout is reflected in the returned logs").contains("stdout"); } } @Test - public void getLogsReturnsStdErrToDate() { + void getLogsReturnsStdErrToDate() { try (GenericContainer container = shortLivedContainer()) { container.start(); // docsGetStdErr { - final String logs = container.getLogs(STDERR); + final String logs = container.getLogs(OutputFrame.OutputType.STDERR); // } - assertTrue("stderr is reflected in the returned logs", logs.contains("stderr")); + assertThat(logs).as("stderr is reflected in the returned logs").contains("stderr"); } } @Test - public void getLogsForLongRunningContainer() throws InterruptedException { + void getLogsForLongRunningContainer() throws InterruptedException { try (GenericContainer container = longRunningContainer()) { container.start(); Thread.sleep(1000L); - final String logs = container.getLogs(STDOUT); - assertTrue("stdout is reflected in the returned logs for a running container", logs.contains("seq=0")); + 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<>(ALPINE_IMAGE) + 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<>(ALPINE_IMAGE) - .withCommand("ping -c 100 127.0.0.1"); + 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 index 325cccdd378..33ae9fd17f5 100644 --- a/core/src/test/java/org/testcontainers/containers/output/FrameConsumerResultCallbackTest.java +++ b/core/src/test/java/org/testcontainers/containers/output/FrameConsumerResultCallbackTest.java @@ -2,7 +2,7 @@ import com.github.dockerjava.api.model.Frame; import com.github.dockerjava.api.model.StreamType; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -10,138 +10,200 @@ import java.util.concurrent.TimeoutException; import java.util.function.Consumer; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import static org.testcontainers.containers.output.OutputFrame.OutputType; +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"; -public 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 - public void passStderrFrameWithoutColors() { + void passStderrFrameWithoutColors() throws IOException { FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); ToStringConsumer consumer = new ToStringConsumer(); - callback.addConsumer(OutputType.STDERR, consumer); + callback.addConsumer(OutputFrame.OutputType.STDERR, consumer); callback.onNext(new Frame(StreamType.STDERR, FRAME_PAYLOAD.getBytes())); - assertEquals(LOG_RESULT, consumer.toUtf8String()); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(LOG_RESULT); } @Test - public void passStderrFrameWithColors() { + void passStderrFrameWithColors() throws IOException { FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); ToStringConsumer consumer = new ToStringConsumer().withRemoveAnsiCodes(false); - callback.addConsumer(OutputType.STDERR, consumer); + callback.addConsumer(OutputFrame.OutputType.STDERR, consumer); callback.onNext(new Frame(StreamType.STDERR, FRAME_PAYLOAD.getBytes())); - assertEquals(FRAME_PAYLOAD, consumer.toUtf8String()); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(FRAME_PAYLOAD); } @Test - public void passStdoutFrameWithoutColors() { + void passStdoutFrameWithoutColors() throws IOException { FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); ToStringConsumer consumer = new ToStringConsumer(); - callback.addConsumer(OutputType.STDOUT, consumer); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); callback.onNext(new Frame(StreamType.STDOUT, FRAME_PAYLOAD.getBytes())); - assertEquals(LOG_RESULT, consumer.toUtf8String()); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(LOG_RESULT); } @Test - public void passStdoutFrameWithColors() { + void passStdoutFrameWithColors() throws IOException { FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); ToStringConsumer consumer = new ToStringConsumer().withRemoveAnsiCodes(false); - callback.addConsumer(OutputType.STDOUT, consumer); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); callback.onNext(new Frame(StreamType.STDOUT, FRAME_PAYLOAD.getBytes())); - assertEquals(FRAME_PAYLOAD, consumer.toUtf8String()); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(FRAME_PAYLOAD); } @Test - public void basicConsumer() { + void basicConsumer() throws IOException { FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); BasicConsumer consumer = new BasicConsumer(); - callback.addConsumer(OutputType.STDOUT, consumer); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); callback.onNext(new Frame(StreamType.STDOUT, FRAME_PAYLOAD.getBytes())); - assertEquals(LOG_RESULT, consumer.toString()); + callback.close(); + assertThat(consumer.toString()).isEqualTo(LOG_RESULT); } @Test - public void passStdoutNull() { + void passStdoutNull() throws IOException { FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); ToStringConsumer consumer = new ToStringConsumer().withRemoveAnsiCodes(false); - callback.addConsumer(OutputType.STDOUT, consumer); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); callback.onNext(new Frame(StreamType.STDOUT, null)); - assertEquals("", consumer.toUtf8String()); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(""); } @Test - public void passStdoutEmptyLine() { + void passStdoutEmptyLine() throws IOException { String payload = ""; FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); ToStringConsumer consumer = new ToStringConsumer().withRemoveAnsiCodes(false); - callback.addConsumer(OutputType.STDOUT, consumer); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); callback.onNext(new Frame(StreamType.STDOUT, payload.getBytes())); - assertEquals(payload, consumer.toUtf8String()); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(payload); } @Test - public void passStdoutSingleLine() { + void passStdoutSingleLine() throws IOException { String payload = "Test"; FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); ToStringConsumer consumer = new ToStringConsumer().withRemoveAnsiCodes(false); - callback.addConsumer(OutputType.STDOUT, consumer); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); callback.onNext(new Frame(StreamType.STDOUT, payload.getBytes())); - assertEquals(payload, consumer.toUtf8String()); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(payload); } @Test - public void passStdoutSingleLineWithNewline() { + void passStdoutSingleLineWithNewline() throws IOException { String payload = "Test\n"; FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); ToStringConsumer consumer = new ToStringConsumer().withRemoveAnsiCodes(false); - callback.addConsumer(OutputType.STDOUT, consumer); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); callback.onNext(new Frame(StreamType.STDOUT, payload.getBytes())); - assertEquals(payload, consumer.toUtf8String()); + callback.close(); + assertThat(consumer.toUtf8String()).isEqualTo(payload); } @Test - public void passRawFrameWithoutColors() throws TimeoutException, IOException { + void passRawFrameWithoutColors() throws TimeoutException, IOException { FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); WaitingConsumer waitConsumer = new WaitingConsumer(); - callback.addConsumer(OutputType.STDOUT, waitConsumer); + callback.addConsumer(OutputFrame.OutputType.STDOUT, waitConsumer); callback.onNext(new Frame(StreamType.RAW, FRAME_PAYLOAD.getBytes())); - waitConsumer.waitUntil(frame -> frame.getType() == OutputType.STDOUT && frame.getUtf8String().equals("Test2"), 1, TimeUnit.SECONDS); - waitConsumer.waitUntil(frame -> frame.getType() == OutputType.STDOUT && frame.getUtf8String().equals("Тест1"), 1, TimeUnit.SECONDS); + 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() == OutputType.STDOUT && frame.getUtf8String().equals("Test3"), 1, TimeUnit.SECONDS); + waitConsumer.waitUntil( + frame -> frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().equals("Test3"), + 1, + TimeUnit.SECONDS + ); } catch (Exception e) { exception = e; } - assertTrue(exception instanceof TimeoutException); + assertThat(exception instanceof TimeoutException).isTrue(); callback.close(); - waitConsumer.waitUntil(frame -> frame.getType() == OutputType.STDOUT && frame.getUtf8String().equals("Test3"), 1, TimeUnit.SECONDS); + waitConsumer.waitUntil( + frame -> frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().equals("Test3"), + 1, + TimeUnit.SECONDS + ); } @Test - public void passRawFrameWithColors() throws TimeoutException, IOException { + void passRawFrameWithColors() throws TimeoutException, IOException { FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); WaitingConsumer waitConsumer = new WaitingConsumer().withRemoveAnsiCodes(false); - callback.addConsumer(OutputType.STDOUT, waitConsumer); + callback.addConsumer(OutputFrame.OutputType.STDOUT, waitConsumer); callback.onNext(new Frame(StreamType.RAW, FRAME_PAYLOAD.getBytes())); - waitConsumer.waitUntil(frame -> frame.getType() == OutputType.STDOUT && frame.getUtf8String().equals("\u001B[1;33mTest2\u001B[0m"), 1, TimeUnit.SECONDS); - waitConsumer.waitUntil(frame -> frame.getType() == OutputType.STDOUT && frame.getUtf8String().equals("\u001B[0;32mТест1\u001B[0m"), 1, TimeUnit.SECONDS); + 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 -> frame.getType() == OutputType.STDOUT && frame.getUtf8String().equals("\u001B[0;31mTest3\u001B[0m"), 1, TimeUnit.SECONDS); + 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; } - assertTrue(exception instanceof TimeoutException); + assertThat(exception instanceof TimeoutException).isTrue(); callback.close(); - waitConsumer.waitUntil(frame -> frame.getType() == OutputType.STDOUT && frame.getUtf8String().equals("\u001B[0;31mTest3\u001B[0m"), 1, TimeUnit.SECONDS); + waitConsumer.waitUntil( + frame -> { + return ( + frame.getType() == OutputFrame.OutputType.STDOUT && + frame.getUtf8String().equals("\u001B[0;31mTest3\u001B[0m") + ); + }, + 1, + TimeUnit.SECONDS + ); } @Test - public void reconstructBreakedUnicode() throws IOException { + void reconstructBreakedUnicode() throws IOException { String payload = "Тест"; byte[] payloadBytes = payload.getBytes(StandardCharsets.UTF_8); byte[] bytes1 = new byte[(int) (payloadBytes.length * 0.6)]; @@ -150,23 +212,19 @@ public void reconstructBreakedUnicode() throws IOException { System.arraycopy(payloadBytes, bytes1.length, bytes2, 0, bytes2.length); FrameConsumerResultCallback callback = new FrameConsumerResultCallback(); ToStringConsumer consumer = new ToStringConsumer().withRemoveAnsiCodes(false); - callback.addConsumer(OutputType.STDOUT, consumer); + callback.addConsumer(OutputFrame.OutputType.STDOUT, consumer); callback.onNext(new Frame(StreamType.RAW, bytes1)); callback.onNext(new Frame(StreamType.RAW, bytes2)); callback.close(); - assertEquals(payload, consumer.toUtf8String()); + assertThat(consumer.toUtf8String()).isEqualTo(payload); } private static class BasicConsumer implements Consumer { - private boolean firstLine = true; + private StringBuilder input = new StringBuilder(); @Override public void accept(OutputFrame outputFrame) { - if (!firstLine) { - input.append('\n'); - } - firstLine = false; input.append(outputFrame.getUtf8String()); } diff --git a/core/src/test/java/org/testcontainers/containers/output/ToStringConsumerTest.java b/core/src/test/java/org/testcontainers/containers/output/ToStringConsumerTest.java index 1610c688931..45472771168 100644 --- a/core/src/test/java/org/testcontainers/containers/output/ToStringConsumerTest.java +++ b/core/src/test/java/org/testcontainers/containers/output/ToStringConsumerTest.java @@ -1,77 +1,69 @@ package org.testcontainers.containers.output; -import static org.assertj.core.api.Assertions.assertThat; - -import org.apache.commons.lang.RandomStringUtils; -import org.assertj.core.api.Assertions; -import org.junit.Test; +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; -public class ToStringConsumerTest { +import static org.assertj.core.api.Assertions.assertThat; + +class ToStringConsumerTest { private static final String LARGE_PAYLOAD; static { - StringBuilder builder = new StringBuilder(10_003 * 10);; + 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(); - Assertions.assertThat(LARGE_PAYLOAD).doesNotContain("\n"); + assertThat(LARGE_PAYLOAD).doesNotContain("\n"); } @Test - public void newlines_are_not_added_to_exec_output() throws Exception { - try (GenericContainer container = new GenericContainer<>("alpine:3.13")) { + 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); - Assertions.assertThat(build.getStdout()) - .doesNotContain("\n") - .isEqualTo(LARGE_PAYLOAD); + assertThat(build.getStdout()).doesNotContain("\n").isEqualTo(LARGE_PAYLOAD); } } - @Test(timeout = 60_000L) - public void newlines_are_not_added_to_exec_output_with_tty() throws Exception { - try (GenericContainer container = new GenericContainer<>("alpine:3.13")) { + @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); + 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"); + assertThat(build.getStdout()).isEqualTo(LARGE_PAYLOAD).doesNotContain("\n"); } } @Test - public void newlines_are_not_added_to_container_output() { - try (GenericContainer container = new GenericContainer<>("alpine:3.13")) { + 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"); + assertThat(container.getLogs()).isEqualTo(LARGE_PAYLOAD).doesNotContain("\n"); } } @Test - public void newlines_are_not_added_to_container_output_with_tty() { - try (GenericContainer container = new GenericContainer<>("alpine:3.13")) { + void newlines_are_not_added_to_container_output_with_tty() { + try (GenericContainer container = new GenericContainer<>("alpine:3.17")) { container.withCreateContainerCmdModifier(cmd -> { cmd.withTty(true); }); @@ -81,9 +73,7 @@ public void newlines_are_not_added_to_container_output_with_tty() { container.getDockerClient().waitContainerCmd(container.getContainerId()).start().awaitStatusCode(); - assertThat(container.getLogs()) - .isEqualTo(LARGE_PAYLOAD) - .doesNotContain("\n"); + 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 index 19f3a772d41..c494100544f 100644 --- a/core/src/test/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheckTest.java +++ b/core/src/test/java/org/testcontainers/containers/wait/internal/ExternalPortListeningCheckTest.java @@ -1,26 +1,29 @@ package org.testcontainers.containers.wait.internal; import com.google.common.collect.ImmutableSet; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.rnorth.visibleassertions.VisibleAssertions; +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; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThrows; -public class ExternalPortListeningCheckTest { +class ExternalPortListeningCheckTest { private ServerSocket listeningSocket1; + private ServerSocket listeningSocket2; + private ServerSocket nonListeningSocket; + private WaitStrategyTarget mockContainer; - @Before + @BeforeEach public void setUp() throws Exception { listeningSocket1 = new ServerSocket(0); listeningSocket2 = new ServerSocket(0); @@ -33,37 +36,42 @@ public void setUp() throws Exception { } @Test - public void singleListening() { - - final ExternalPortListeningCheck check = new ExternalPortListeningCheck(mockContainer, ImmutableSet.of(listeningSocket1.getLocalPort())); + void singleListening() { + final ExternalPortListeningCheck check = new ExternalPortListeningCheck( + mockContainer, + ImmutableSet.of(listeningSocket1.getLocalPort()) + ); final Boolean result = check.call(); - VisibleAssertions.assertTrue("ExternalPortListeningCheck identifies a single listening port", result); + assertThat(result).as("ExternalPortListeningCheck identifies a single listening port").isTrue(); } @Test - public void multipleListening() { - - final ExternalPortListeningCheck check = new ExternalPortListeningCheck(mockContainer, ImmutableSet.of(listeningSocket1.getLocalPort(), listeningSocket2.getLocalPort())); + void multipleListening() { + final ExternalPortListeningCheck check = new ExternalPortListeningCheck( + mockContainer, + ImmutableSet.of(listeningSocket1.getLocalPort(), listeningSocket2.getLocalPort()) + ); final Boolean result = check.call(); - VisibleAssertions.assertTrue("ExternalPortListeningCheck identifies multiple listening port", result); + assertThat(result).as("ExternalPortListeningCheck identifies multiple listening port").isTrue(); } @Test - public void oneNotListening() { - - final ExternalPortListeningCheck check = new ExternalPortListeningCheck(mockContainer, ImmutableSet.of(listeningSocket1.getLocalPort(), nonListeningSocket.getLocalPort())); - - assertThrows("ExternalPortListeningCheck detects a non-listening port among many", - IllegalStateException.class, - (Runnable) check::call); - + 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); } - @After + @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 index 00c0bc9d1ef..936be73fd3e 100644 --- a/core/src/test/java/org/testcontainers/containers/wait/internal/InternalCommandPortListeningCheckTest.java +++ b/core/src/test/java/org/testcontainers/containers/wait/internal/InternalCommandPortListeningCheckTest.java @@ -1,78 +1,74 @@ package org.testcontainers.containers.wait.internal; import com.google.common.collect.ImmutableSet; -import org.junit.Rule; -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.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 java.util.Arrays.asList; -import static org.rnorth.visibleassertions.VisibleAssertions.assertFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.assertj.core.api.Assertions.fail; -@RunWith(Parameterized.class) -public class InternalCommandPortListeningCheckTest { +@ParameterizedClass(name = "{index} - {0}") +@MethodSource("data") +class InternalCommandPortListeningCheckTest { - @Parameterized.Parameters(name = "{index} - {0}") - public static Iterable data() { - return asList( - new Object[][]{ - {"internal-port-check-dockerfile/Dockerfile-tcp"}, - {"internal-port-check-dockerfile/Dockerfile-nc"}, - {"internal-port-check-dockerfile/Dockerfile-bash"}, - }); + 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" + ); } - @Rule - public GenericContainer container; + 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 = + new GenericContainer<>( + new ImageFromDockerfile() + .withFileFromClasspath("Dockerfile", dockerfile) + .withFileFromClasspath("nginx.conf", "internal-port-check-dockerfile/nginx.conf") + ); + container.start(); } @Test - public void singleListening() { - final InternalCommandPortListeningCheck check = new InternalCommandPortListeningCheck(container, ImmutableSet.of(8080)); + void singleListening() { + final InternalCommandPortListeningCheck check = new InternalCommandPortListeningCheck( + container, + ImmutableSet.of(8080) + ); Unreliables.retryUntilTrue(5, TimeUnit.SECONDS, check); - - final Boolean result = check.call(); - - assertTrue("InternalCommandPortListeningCheck identifies a single listening port", result); } @Test - public void nonListening() { - final InternalCommandPortListeningCheck check = new InternalCommandPortListeningCheck(container, ImmutableSet.of(8080, 1234)); + void nonListening() { + final InternalCommandPortListeningCheck check = new InternalCommandPortListeningCheck( + container, + ImmutableSet.of(8080, 1234) + ); try { Unreliables.retryUntilTrue(5, TimeUnit.SECONDS, check); - } catch (TimeoutException e) { - // we expect it to timeout - } - - final Boolean result = check.call(); - - assertFalse("InternalCommandPortListeningCheck detects a non-listening port among many", result); + fail("expected to fail"); + } catch (TimeoutException e) {} } @Test - public void lowAndHighPortListening() { - final InternalCommandPortListeningCheck check = new InternalCommandPortListeningCheck(container, ImmutableSet.of(100, 8080)); + void lowAndHighPortListening() { + final InternalCommandPortListeningCheck check = new InternalCommandPortListeningCheck( + container, + ImmutableSet.of(100, 8080) + ); Unreliables.retryUntilTrue(5, TimeUnit.SECONDS, check); - - final Boolean result = check.call(); - - assertTrue("InternalCommandPortListeningCheck identifies a low and a high port", result); } } 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 index d867233f5d7..653bbca2e33 100644 --- a/core/src/test/java/org/testcontainers/containers/wait/strategy/DockerHealthcheckWaitStrategyTest.java +++ b/core/src/test/java/org/testcontainers/containers/wait/strategy/DockerHealthcheckWaitStrategyTest.java @@ -1,36 +1,45 @@ package org.testcontainers.containers.wait.strategy; -import org.junit.Before; -import org.junit.Test; +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.rnorth.visibleassertions.VisibleAssertions.assertThrows; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; -public class DockerHealthcheckWaitStrategyTest { +class DockerHealthcheckWaitStrategyTest { private GenericContainer container; - @Before + @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))); + 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 - public void startsOnceHealthy() { + void startsOnceHealthy() { container.start(); } @Test - public void containerStartFailsIfContainerIsUnhealthy() { + void containerStartFailsIfContainerIsUnhealthy() { container.withCommand("tail", "-f", "/dev/null"); - assertThrows("Container launch fails when unhealthy", ContainerLaunchException.class, container::start); + 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 index 897293282bd..aeebe425776 100644 --- a/core/src/test/java/org/testcontainers/containers/wait/strategy/WaitAllStrategyTest.java +++ b/core/src/test/java/org/testcontainers/containers/wait/strategy/WaitAllStrategyTest.java @@ -1,7 +1,7 @@ package org.testcontainers.containers.wait.strategy; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -10,23 +10,31 @@ 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.*; -import static org.rnorth.visibleassertions.VisibleAssertions.*; +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; -public class WaitAllStrategyTest { +class WaitAllStrategyTest { @Mock private GenericContainer container; + @Mock private WaitStrategy strategy1; + @Mock private WaitStrategy strategy2; + @Mock private WaitStrategy strategy3; - @Before + @BeforeEach public void setUp() { MockitoAnnotations.initMocks(this); } @@ -35,65 +43,59 @@ public void setUp() { * Dummy-based tests, to check that timeout values are propagated correctly, without involving actual timing-sensitive code */ @Test - public void parentTimeoutApplies() { - + void parentTimeoutApplies() { DummyStrategy child1 = new DummyStrategy(Duration.ofMillis(10)); child1.withStartupTimeout(Duration.ofMillis(20)); - assertEquals("withStartupTimeout directly sets the timeout", 20L, child1.startupTimeout.toMillis()); + assertThat(child1.startupTimeout.toMillis()).as("withStartupTimeout directly sets the timeout").isEqualTo(20L); - new WaitAllStrategy() - .withStrategy(child1) - .withStartupTimeout(Duration.ofMillis(30)); + new WaitAllStrategy().withStrategy(child1).withStartupTimeout(Duration.ofMillis(30)); - assertEquals("WaitAllStrategy overrides a child's timeout", 30L, child1.startupTimeout.toMillis()); + assertThat(child1.startupTimeout.toMillis()).as("WaitAllStrategy overrides a child's timeout").isEqualTo(30L); } @Test - public void parentTimeoutAppliesToMultipleChildren() { - + 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); + new WaitAllStrategy().withStrategy(child1).withStrategy(child2).withStartupTimeout(outerWait); - assertEquals("WaitAllStrategy overrides a child's timeout (1st)", 6L, child1.startupTimeout.toMillis()); - assertEquals("WaitAllStrategy overrides a child's timeout (2nd)", 6L, child2.startupTimeout.toMillis()); + 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 - public void parentTimeoutAppliesToAdditionalChildren() { - + 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); + new WaitAllStrategy().withStrategy(child1).withStartupTimeout(outerWait).withStrategy(child2); - assertEquals("WaitAllStrategy overrides a child's timeout (1st)", 20L, child1.startupTimeout.toMillis()); - assertEquals("WaitAllStrategy overrides a child's timeout (2nd, additional)", 20L, child2.startupTimeout.toMillis()); + 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 - public void childExecutionTest() { - - final WaitStrategy underTest = new WaitAllStrategy() - .withStrategy(strategy1) - .withStrategy(strategy2); + void childExecutionTest() { + final WaitStrategy underTest = new WaitAllStrategy().withStrategy(strategy1).withStrategy(strategy2); doNothing().when(strategy1).waitUntilReady(eq(container)); doNothing().when(strategy2).waitUntilReady(eq(container)); @@ -106,8 +108,7 @@ public void childExecutionTest() { } @Test - public void withoutOuterTimeoutShouldRelyOnInnerStrategies() { - + void withoutOuterTimeoutShouldRelyOnInnerStrategies() { final WaitStrategy underTest = new WaitAllStrategy(WaitAllStrategy.Mode.WITH_INDIVIDUAL_TIMEOUTS_ONLY) .withStrategy(strategy1) .withStrategy(strategy2) @@ -116,9 +117,13 @@ public void withoutOuterTimeoutShouldRelyOnInnerStrategies() { doNothing().when(strategy1).waitUntilReady(eq(container)); doThrow(TimeoutException.class).when(strategy2).waitUntilReady(eq(container)); - assertThrows("The outer strategy timeout applies", TimeoutException.class, () -> { - underTest.waitUntilReady(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()); @@ -127,18 +132,20 @@ public void withoutOuterTimeoutShouldRelyOnInnerStrategies() { } @Test - public void timeoutChangeShouldNotBePossibleWithIndividualTimeoutMode() { - + void timeoutChangeShouldNotBePossibleWithIndividualTimeoutMode() { final WaitStrategy underTest = new WaitAllStrategy(WaitAllStrategy.Mode.WITH_INDIVIDUAL_TIMEOUTS_ONLY); - assertThrows("Cannot change timeout for individual timeouts", IllegalStateException.class, () -> { - underTest.withStartupTimeout(Duration.ofSeconds(42)); - }); + assertThat( + catchThrowable(() -> { + underTest.withStartupTimeout(Duration.ofSeconds(42)); + }) + ) + .as("Cannot change timeout for individual timeouts") + .isInstanceOf(IllegalStateException.class); } @Test - public void shouldNotMessWithIndividualTimeouts() { - + void shouldNotMessWithIndividualTimeouts() { new WaitAllStrategy(WaitAllStrategy.Mode.WITH_INDIVIDUAL_TIMEOUTS_ONLY) .withStrategy(strategy1) .withStrategy(strategy2); @@ -148,19 +155,16 @@ public void shouldNotMessWithIndividualTimeouts() { } @Test - public void shouldOverwriteIndividualTimeouts() { - + void shouldOverwriteIndividualTimeouts() { Duration someSeconds = Duration.ofSeconds(23); - new WaitAllStrategy() - .withStartupTimeout(someSeconds) - .withStrategy(strategy1) - .withStrategy(strategy2); + 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; } 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 index 9e62148c3bb..df351b9fd74 100644 --- a/core/src/test/java/org/testcontainers/dockerclient/AmbiguousImagePullTest.java +++ b/core/src/test/java/org/testcontainers/dockerclient/AmbiguousImagePullTest.java @@ -1,32 +1,26 @@ package org.testcontainers.dockerclient; -import com.github.dockerjava.api.DockerClient; -import com.github.dockerjava.api.model.Image; -import org.junit.Test; -import org.testcontainers.DockerClientFactory; +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; -import java.util.List; +class AmbiguousImagePullTest { -public class AmbiguousImagePullTest { - - @Test(timeout = 30_000) - public void testNotUsingParse() { - DockerClient client = DockerClientFactory.instance().client(); - List alpineImages = client.listImagesCmd() - .withImageNameFilter("testcontainers/helloworld:latest") - .exec(); - for (Image alpineImage : alpineImages) { - client.removeImageCmd(alpineImage.getId()).exec(); - } - - try ( - final GenericContainer container = new GenericContainer<>(DockerImageName.parse("testcontainers/helloworld")) - .withExposedPorts(8080) - ) { - container.start(); - // do nothing other than start and stop + @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 442c2efd950..f3bc15d2c22 100644 --- a/core/src/test/java/org/testcontainers/dockerclient/DockerClientConfigUtilsTest.java +++ b/core/src/test/java/org/testcontainers/dockerclient/DockerClientConfigUtilsTest.java @@ -2,49 +2,63 @@ import com.github.dockerjava.api.DockerClient; import org.assertj.core.api.Assumptions; -import org.junit.Test; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.testcontainers.DockerClientFactory; import java.net.URI; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; +import static org.assertj.core.api.Assertions.assertThat; -public class DockerClientConfigUtilsTest { +class DockerClientConfigUtilsTest { DockerClient client = DockerClientFactory.lazyClient(); @Test - public void getDockerHostIpAddressShouldReturnLocalhostWhenUnixSocket() { - Assumptions.assumeThat(DockerClientConfigUtils.IN_A_CONTAINER) - .as("in a container") - .isFalse(); + 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")); - assertEquals("localhost", actual); + String actual = DockerClientProviderStrategy.resolveDockerHostIpAddress( + client, + URI.create("unix:///var/run/docker.sock"), + true + ); + assertThat(actual).isEqualTo("localhost"); } @Test - public void getDockerHostIpAddressShouldReturnDockerHostIpWhenHttpsUri() { - String actual = DockerClientProviderStrategy.resolveDockerHostIpAddress(client, URI.create("http://12.23.34.45")); - assertEquals("12.23.34.45", actual); + void getDockerHostIpAddressShouldReturnDockerHostIpWhenHttpsUri() { + String actual = DockerClientProviderStrategy.resolveDockerHostIpAddress( + client, + URI.create("http://12.23.34.45"), + true + ); + assertThat(actual).isEqualTo("12.23.34.45"); } @Test - public void getDockerHostIpAddressShouldReturnDockerHostIpWhenTcpUri() { - String actual = DockerClientProviderStrategy.resolveDockerHostIpAddress(client, URI.create("tcp://12.23.34.45")); - assertEquals("12.23.34.45", actual); + 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 getDockerHostIpAddressShouldReturnNullWhenUnsupportedUriScheme() { - String actual = DockerClientProviderStrategy.resolveDockerHostIpAddress(client, URI.create("gopher://12.23.34.45")); - assertNull(actual); + void getDockerHostIpAddressShouldReturnNullWhenUnsupportedUriScheme() { + String actual = DockerClientProviderStrategy.resolveDockerHostIpAddress( + client, + URI.create("gopher://12.23.34.45"), + true + ); + assertThat(actual).isNull(); } - @Test(timeout = 5_000) - public void getDefaultGateway() { - assertNotNull(DockerClientConfigUtils.getDefaultGateway()); + @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 index a43a001ca5b..2976c56c7a9 100644 --- a/core/src/test/java/org/testcontainers/dockerclient/EventStreamTest.java +++ b/core/src/test/java/org/testcontainers/dockerclient/EventStreamTest.java @@ -4,10 +4,10 @@ import com.github.dockerjava.api.model.Event; import com.github.dockerjava.core.command.EventsResultCallback; import org.jetbrains.annotations.NotNull; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.Timeout; +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; @@ -16,25 +16,21 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import static org.testcontainers.TestImages.TINY_IMAGE; - /** * Test that event streaming from the {@link DockerClient} works correctly */ -public class EventStreamTest { - - @Rule - public Timeout timeout = new Timeout(10, TimeUnit.SECONDS); +@Timeout(10) +class EventStreamTest { /** * Test that docker events can be streamed from the client. */ @Test - public void test() throws IOException, InterruptedException { + void test() throws IOException, InterruptedException { CountDownLatch latch = new CountDownLatch(1); try ( - GenericContainer container = new GenericContainer<>(TINY_IMAGE) + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) .withCommand("true") .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) ) { @@ -43,24 +39,31 @@ public void test() throws IOException, InterruptedException { // Request all events between startTime and endTime for the container try ( - EventsResultCallback response = DockerClientFactory.instance().client().eventsCmd() + 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(); + .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 index fd936d26c19..d5329430e71 100644 --- a/core/src/test/java/org/testcontainers/dockerclient/ImagePullTest.java +++ b/core/src/test/java/org/testcontainers/dockerclient/ImagePullTest.java @@ -1,43 +1,34 @@ package org.testcontainers.dockerclient; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; +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; -@RunWith(Parameterized.class) -public class ImagePullTest { +class ImagePullTest { - private final String image; - - @Parameterized.Parameters(name = "{0}") public static String[] parameters() { return new String[] { "alpine:latest", - "alpine:3.6", + "alpine:3.17", "alpine", // omitting the tag should work and default to latest - "alpine@sha256:8fd4b76819e1e5baac82bd0a3d03abfe3906e034cc5ee32100d12aaaf3956dc7", - "gliderlabs/alpine:latest", - "gliderlabs/alpine:3.5", - "gliderlabs/alpine@sha256:a19aa4a17a525c97e5a90a0c53a9f3329d2dc61b0a14df5447757a865671c085", - "quay.io/testcontainers/ryuk:latest", - "quay.io/testcontainers/ryuk:0.2.3", - "quay.io/testcontainers/ryuk@sha256:bb5a635cac4bd96c93cc476969ce11dc56436238ec7cd028d0524462f4739dd9", -// "ibmcom/db2express-c", // Big image for testing with slow networks + "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 }; } - public ImagePullTest(String image) { - this.image = image; - } - - @Test - public void test() { - try (final GenericContainer container = new GenericContainer<>(DockerImageName.parse(image)) - .withCommand("/bin/sh", "-c", "sleep 0") - .withStartupCheckStrategy(new OneShotStartupCheckStrategy())) { + @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 index e073fa0522a..254633ea6e7 100644 --- a/core/src/test/java/org/testcontainers/images/AgeBasedPullPolicyTest.java +++ b/core/src/test/java/org/testcontainers/images/AgeBasedPullPolicyTest.java @@ -1,30 +1,27 @@ package org.testcontainers.images; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; +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 org.junit.Test; -import org.testcontainers.utility.DockerImageName; +import static org.assertj.core.api.Assertions.assertThat; -public class AgeBasedPullPolicyTest { +class AgeBasedPullPolicyTest { final DockerImageName imageName = DockerImageName.parse(UUID.randomUUID().toString()); @Test - public void shouldPull() { - ImageData imageData = ImageData.builder() - .createdAt(Instant.now().minus(2, ChronoUnit.HOURS)) - .build(); + void shouldPull() { + ImageData imageData = ImageData.builder().createdAt(Instant.now().minus(2, ChronoUnit.HOURS)).build(); AgeBasedPullPolicy oneHour = new AgeBasedPullPolicy(Duration.of(1L, ChronoUnit.HOURS)); - assertTrue(oneHour.shouldPullCached(imageName, imageData)); + assertThat(oneHour.shouldPullCached(imageName, imageData)).isTrue(); AgeBasedPullPolicy fiveHours = new AgeBasedPullPolicy(Duration.of(5L, ChronoUnit.HOURS)); - assertFalse(fiveHours.shouldPullCached(imageName, imageData)); + 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 index 3b473cf7ffd..b9fe0ae3c42 100644 --- a/core/src/test/java/org/testcontainers/images/ImageDataTest.java +++ b/core/src/test/java/org/testcontainers/images/ImageDataTest.java @@ -1,27 +1,25 @@ package org.testcontainers.images; -import static org.assertj.core.api.Assertions.assertThat; +import com.github.dockerjava.api.command.InspectImageResponse; +import org.junit.jupiter.api.Test; import java.time.Instant; -import org.junit.Test; - -import com.github.dockerjava.api.command.InspectImageResponse; +import static org.assertj.core.api.Assertions.assertThat; -public class ImageDataTest { +class ImageDataTest { @Test - public void shouldReadTimestampWithoutOffsetFromInspectImageResponse() { + 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 - public void shouldReadTimestampWithOffsetFromInspectImageResponse() { + 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 index 9b22f5e6166..6c763de2962 100644 --- a/core/src/test/java/org/testcontainers/images/ImagePullPolicyTest.java +++ b/core/src/test/java/org/testcontainers/images/ImagePullPolicyTest.java @@ -1,96 +1,46 @@ package org.testcontainers.images; -import com.github.dockerjava.api.DockerClient; -import com.github.dockerjava.api.async.ResultCallback; -import com.github.dockerjava.api.command.PullImageResultCallback; import com.github.dockerjava.api.exception.NotFoundException; -import org.junit.AfterClass; -import org.junit.Before; -import org.junit.BeforeClass; -import org.junit.ClassRule; -import org.junit.Test; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import org.rnorth.visibleassertions.VisibleAssertions; 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 java.util.UUID; -import java.util.concurrent.TimeUnit; - -import static org.junit.Assert.fail; +import static org.assertj.core.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; -import static org.testcontainers.TestImages.DOCKER_REGISTRY_IMAGE; - -public class ImagePullPolicyTest { - - @ClassRule - public static GenericContainer registry = new GenericContainer<>(DOCKER_REGISTRY_IMAGE) - .withExposedPorts(5000); - - private static DockerImageName imageName; - - @BeforeClass - public static void beforeClass() throws Exception { - String testRegistryAddress = registry.getHost() + ":" + registry.getFirstMappedPort(); - String testImageName = testRegistryAddress + "/image-pull-policy-test"; - String tag = UUID.randomUUID().toString(); - imageName = DockerImageName.parse(testImageName).withTag(tag); - - DockerClient client = DockerClientFactory.instance().client(); - String dummySourceImage = "hello-world:latest"; - client.pullImageCmd(dummySourceImage).exec(new PullImageResultCallback()).awaitCompletion(); - - String dummyImageId = client.inspectImageCmd(dummySourceImage).exec().getId(); - // push the image to the registry - client.tagImageCmd(dummyImageId, testImageName, tag).exec(); +class ImagePullPolicyTest { - client.pushImageCmd(imageName.asCanonicalNameString()) - .exec(new ResultCallback.Adapter<>()) - .awaitCompletion(1, TimeUnit.MINUTES); - } + @AutoClose + public static DockerRegistryContainer registry = new DockerRegistryContainer(); - @AfterClass - public static void afterClass() { - removeImage(); - } + private final DockerImageName imageName = registry.createImage(); - @Before - public void setUp() { - // Clean up local cache - removeImage(); - - LocalImagesCache.INSTANCE.cache.remove(imageName); + static { + registry.start(); } @Test - public void pullsByDefault() { - try ( - GenericContainer container = new GenericContainer<>(imageName) - .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) - ) { + void pullsByDefault() { + try (GenericContainer container = new GenericContainer<>(imageName).withExposedPorts(8080)) { container.start(); } } @Test - public void shouldAlwaysPull() { - try ( - GenericContainer container = new GenericContainer<>(imageName) - .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) - ) { + void shouldAlwaysPull() { + try (GenericContainer container = new GenericContainer<>(imageName).withExposedPorts(8080)) { container.start(); } removeImage(); - try ( - GenericContainer container = new GenericContainer<>(imageName) - .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) - ) { + try (GenericContainer container = new GenericContainer<>(imageName).withExposedPorts(8080)) { expectToFailWithNotFoundException(container); } @@ -100,41 +50,45 @@ public void shouldAlwaysPull() { .withImagePullPolicy(PullPolicy.alwaysPull()) // } ) { - container.withStartupCheckStrategy(new OneShotStartupCheckStrategy()); + container.withExposedPorts(8080); container.start(); } } @Test - public void shouldSupportCustomPolicies() { + 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; + .withImagePullPolicy( + new AbstractImagePullPolicy() { + @Override + protected boolean shouldPullCached(DockerImageName imageName, ImageData localImageData) { + return System.getenv("ALWAYS_PULL_IMAGE") != null; + } } - }) + ) // } ) { - container.withStartupCheckStrategy(new OneShotStartupCheckStrategy()); + container.withExposedPorts(8080); container.start(); } } @Test - public void shouldCheckPolicy() { - ImagePullPolicy policy = Mockito.spy(new AbstractImagePullPolicy() { - @Override - protected boolean shouldPullCached(DockerImageName imageName, ImageData localImageData) { - return false; + 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) - .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + .withExposedPorts(8080) ) { container.start(); @@ -143,7 +97,7 @@ protected boolean shouldPullCached(DockerImageName imageName, ImageData localIma } @Test - public void shouldNotForcePulling() { + void shouldNotForcePulling() { try ( GenericContainer container = new GenericContainer<>(imageName) .withImagePullPolicy(__ -> false) @@ -162,21 +116,21 @@ private void expectToFailWithNotFoundException(GenericContainer container) { while (throwable.getCause() != null) { throwable = throwable.getCause(); if (throwable.getCause() instanceof NotFoundException) { - VisibleAssertions.pass("Caused by NotFoundException"); return; } } - VisibleAssertions.fail("Caused by NotFoundException"); + fail("Caused by NotFoundException"); } } - private static void removeImage() { + private void removeImage() { try { - DockerClientFactory.instance().client() + DockerClientFactory + .instance() + .client() .removeImageCmd(imageName.asCanonicalNameString()) .withForce(true) .exec(); - } catch (NotFoundException ignored) { - } + } 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 index 52a22c83b00..e881f0167e2 100644 --- a/core/src/test/java/org/testcontainers/images/ParsedDockerfileTest.java +++ b/core/src/test/java/org/testcontainers/images/ParsedDockerfileTest.java @@ -1,72 +1,110 @@ package org.testcontainers.images; import com.google.common.collect.Sets; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.nio.file.Paths; +import java.util.Arrays; -import static java.util.Arrays.asList; -import static org.junit.Assert.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; -public class ParsedDockerfileTest { +class ParsedDockerfileTest { @Test - public void doesSimpleParsing() { - final ParsedDockerfile parsedDockerfile = new ParsedDockerfile(asList("FROM someimage", "RUN something")); - assertEquals("extracts a single image name", Sets.newHashSet("someimage"), parsedDockerfile.getDependencyImageNames()); + 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 - public void isCaseInsensitive() { - final ParsedDockerfile parsedDockerfile = new ParsedDockerfile(asList("from someimage", "RUN something")); - assertEquals("extracts a single image name", Sets.newHashSet("someimage"), parsedDockerfile.getDependencyImageNames()); + 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 - public void handlesTags() { - final ParsedDockerfile parsedDockerfile = new ParsedDockerfile(asList("FROM someimage:tag", "RUN something")); - assertEquals("retains tags in image names", Sets.newHashSet("someimage:tag"), parsedDockerfile.getDependencyImageNames()); + 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 - public void handlesDigests() { - final ParsedDockerfile parsedDockerfile = new ParsedDockerfile(asList("FROM someimage@sha256:abc123", "RUN something")); - assertEquals("retains digests in image names", Sets.newHashSet("someimage@sha256:abc123"), parsedDockerfile.getDependencyImageNames()); + 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 - public void ignoringCommentedFromLines() { - final ParsedDockerfile parsedDockerfile = new ParsedDockerfile(asList("FROM someimage", "#FROM somethingelse")); - assertEquals("ignores commented from lines", Sets.newHashSet("someimage"), parsedDockerfile.getDependencyImageNames()); + 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 - public void ignoringBuildStageNames() { - final ParsedDockerfile parsedDockerfile = new ParsedDockerfile(asList("FROM someimage --as=base", "RUN something", "FROM nextimage", "RUN something")); - assertEquals("ignores build stage names and allows multiple images to be extracted", Sets.newHashSet("someimage", "nextimage"), parsedDockerfile.getDependencyImageNames()); + 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 - public void ignoringPlatformArgs() { - final ParsedDockerfile parsedDockerfile = new ParsedDockerfile(asList("FROM --platform=linux/amd64 someimage", "RUN something")); - assertEquals("ignores platform args", Sets.newHashSet("someimage"), parsedDockerfile.getDependencyImageNames()); + 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 - public void ignoringExtraPlatformArgs() { - final ParsedDockerfile parsedDockerfile = new ParsedDockerfile(asList("FROM --platform=linux/amd64 --somethingelse=value someimage", "RUN something")); - assertEquals("ignores platform args", Sets.newHashSet("someimage"), parsedDockerfile.getDependencyImageNames()); + 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 - public void handlesGracefullyIfNoFromLine() { - final ParsedDockerfile parsedDockerfile = new ParsedDockerfile(asList("RUN something", "# is this even a valid Dockerfile?")); - assertEquals("handles invalid Dockerfiles gracefully", Sets.newHashSet(), parsedDockerfile.getDependencyImageNames()); + 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 - public void handlesGracefullyIfDockerfileNotFound() { + void handlesGracefullyIfDockerfileNotFound() { final ParsedDockerfile parsedDockerfile = new ParsedDockerfile(Paths.get("nonexistent.Dockerfile")); - assertEquals("handles missing Dockerfiles gracefully", Sets.newHashSet(), parsedDockerfile.getDependencyImageNames()); + 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 index d2ea3e7188a..156df3ae7d3 100644 --- a/core/src/test/java/org/testcontainers/images/RemoteDockerImageTest.java +++ b/core/src/test/java/org/testcontainers/images/RemoteDockerImageTest.java @@ -1,6 +1,7 @@ package org.testcontainers.images; -import org.junit.Test; +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; @@ -9,48 +10,48 @@ import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; -import static org.hamcrest.CoreMatchers.containsString; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertThat; +import static org.assertj.core.api.Assertions.assertThat; -public class RemoteDockerImageTest { +class RemoteDockerImageTest { @Test - public void toStringContainsOnlyImageName() { + void toStringContainsOnlyImageName() { String imageName = Base58.randomString(8).toLowerCase(); RemoteDockerImage remoteDockerImage = new RemoteDockerImage(DockerImageName.parse(imageName)); - assertThat(remoteDockerImage.toString(), containsString("imageName=" + imageName)); + assertThat(remoteDockerImage.toString()).contains("imageName=" + imageName); } @Test - public void toStringWithExceptionContainsOnlyImageNameFuture() { + void toStringWithExceptionContainsOnlyImageNameFuture() { CompletableFuture imageNameFuture = new CompletableFuture<>(); imageNameFuture.completeExceptionally(new RuntimeException("arbitrary")); RemoteDockerImage remoteDockerImage = new RemoteDockerImage(imageNameFuture); - assertThat(remoteDockerImage.toString(), containsString("imageName=java.lang.RuntimeException: arbitrary")); + assertThat(remoteDockerImage.toString()).contains("imageName=java.lang.RuntimeException: arbitrary"); } - @Test(timeout=5000L) - public void toStringDoesntResolveImageNameFuture() { + @Test + @Timeout(5) + void toStringDoesntResolveImageNameFuture() { CompletableFuture imageNameFuture = new CompletableFuture<>(); // verify that we've set up the test properly - assertFalse(imageNameFuture.isDone()); + assertThat(imageNameFuture).isNotDone(); RemoteDockerImage remoteDockerImage = new RemoteDockerImage(imageNameFuture); - assertThat(remoteDockerImage.toString(), containsString("imageName=")); + assertThat(remoteDockerImage.toString()).contains("imageName="); // Make sure the act of calling toString doesn't resolve the imageNameFuture - assertFalse(imageNameFuture.isDone()); + assertThat(imageNameFuture).isNotDone(); String imageName = Base58.randomString(8).toLowerCase(); imageNameFuture.complete(imageName); - assertThat(remoteDockerImage.toString(), containsString("imageName=" + imageName)); + assertThat(remoteDockerImage.toString()).contains("imageName=" + imageName); } - @Test(timeout=5000L) - public void toStringDoesntResolveLazyFuture() throws Exception { + @Test + @Timeout(5) + void toStringDoesntResolveLazyFuture() throws Exception { String imageName = Base58.randomString(8).toLowerCase(); AtomicBoolean resolved = new AtomicBoolean(false); Future imageNameFuture = new LazyFuture() { @@ -62,17 +63,17 @@ protected String resolve() { }; // verify that we've set up the test properly - assertFalse(imageNameFuture.isDone()); + assertThat(imageNameFuture).isNotDone(); RemoteDockerImage remoteDockerImage = new RemoteDockerImage(imageNameFuture); - assertThat(remoteDockerImage.toString(), containsString("imageName=")); + assertThat(remoteDockerImage.toString()).contains("imageName="); // Make sure the act of calling toString doesn't resolve the imageNameFuture - assertFalse(imageNameFuture.isDone()); - assertFalse(resolved.get()); + assertThat(imageNameFuture).isNotDone(); + assertThat(resolved).isFalse(); // Trigger resolve imageNameFuture.get(); - assertThat(remoteDockerImage.toString(), containsString("imageName=" + imageName)); + 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 index 9a9d647d71b..fae6fc3193d 100644 --- a/core/src/test/java/org/testcontainers/images/builder/DockerfileBuildTest.java +++ b/core/src/test/java/org/testcontainers/images/builder/DockerfileBuildTest.java @@ -1,75 +1,86 @@ package org.testcontainers.images.builder; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; +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.io.File; 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.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; -@RunWith(Parameterized.class) -public class DockerfileBuildTest { +class DockerfileBuildTest { static final Path RESOURCE_PATH = Paths.get("src/test/resources/dockerfile-build-test"); - public String expectedFileContent; - public ImageFromDockerfile image; + 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"); - @Parameterized.Parameters - public static Object[][] parameters() { - return new Object[][]{ + //noinspection deprecation + return Stream.of( // Dockerfile build without explicit per-file inclusion - new Object[]{"test1234", + Arguments.of( + "test1234", + // spotless:off // docsShowRecursiveFileInclusion { new ImageFromDockerfile() - .withFileFromPath(".", RESOURCE_PATH) + .withFileFromPath(".", RESOURCE_PATH)), // } - }, - + // spotless:on // Dockerfile build using a non-standard Dockerfile - new Object[]{"test4567", - new ImageFromDockerfile() - .withFileFromPath(".", RESOURCE_PATH) - .withDockerfilePath("./Dockerfile-alt") - }, - - // Dockerfile build using build args - new Object[]{"test7890", + 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 withDockerfile(File) - new Object[]{"test4567", + ), + // 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")) - }, - }; - } - - public DockerfileBuildTest(String expectedFileContent, ImageFromDockerfile image) { - this.expectedFileContent = expectedFileContent; - this.image = image; + ) + ); } - @Test - public void performTest() { - try (final GenericContainer container = new GenericContainer(image) - .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) - .withCommand("cat", "/test.txt")) { + @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(); - assertTrue("expected file content indicates that dockerfile build steps have been run", logs.contains(expectedFileContent)); + 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 index b99a70a7650..6f7329f9dd2 100644 --- a/core/src/test/java/org/testcontainers/images/builder/DockerignoreTest.java +++ b/core/src/test/java/org/testcontainers/images/builder/DockerignoreTest.java @@ -1,7 +1,7 @@ package org.testcontainers.images.builder; import com.github.dockerjava.api.exception.DockerClientException; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; import org.testcontainers.utility.DockerImageName; @@ -9,48 +9,48 @@ import java.nio.file.Path; import java.nio.file.Paths; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.rnorth.visibleassertions.VisibleAssertions.fail; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; -public class DockerignoreTest { +class DockerignoreTest { private static final Path INVALID_DOCKERIGNORE_PATH = Paths.get("src/test/resources/dockerfile-build-invalid"); @Test - public void testInvalidDockerignore() throws Exception { + 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")) + } catch (DockerClientException e) { + if (!e.getMessage().contains("Invalid pattern")) { throw e; + } } } @SuppressWarnings("resource") @Test - public void testValidDockerignore() throws Exception { + void testValidDockerignore() throws Exception { ImageFromDockerfile img = new ImageFromDockerfile() - .withFileFromPath(".", DockerfileBuildTest.RESOURCE_PATH) - .withDockerfile(DockerfileBuildTest.RESOURCE_PATH.resolve("Dockerfile-currentdir")); - try( + .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", "/") + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + .withCommand("ls", "/") ) { - container.start(); final String logs = container.getLogs(); - assertTrue("Files in the container indicated the .dockerignore was not applied. Output was: " + logs, - logs.contains("should_not_be_ignored.txt")); - assertTrue("Files in the container indicated the .dockerignore was not applied. Output was: " + logs, - !logs.contains("should_be_ignored.txt")); + 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 index 93ec5cb5cd1..0ce8ec041bf 100644 --- a/core/src/test/java/org/testcontainers/images/builder/ImageFromDockerfileTest.java +++ b/core/src/test/java/org/testcontainers/images/builder/ImageFromDockerfileTest.java @@ -2,17 +2,17 @@ import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.command.InspectImageResponse; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.testcontainers.DockerClientFactory; +import org.testcontainers.utility.Base58; import static org.assertj.core.api.Assertions.assertThat; -public class ImageFromDockerfileTest { +class ImageFromDockerfileTest { @Test - public void shouldAddDefaultLabels() { - ImageFromDockerfile image = new ImageFromDockerfile() - .withDockerfileFromBuilder(it -> it.from("scratch")); + void shouldAddDefaultLabels() { + ImageFromDockerfile image = new ImageFromDockerfile().withDockerfileFromBuilder(it -> it.from("scratch")); String imageId = image.resolve(); @@ -24,4 +24,24 @@ public void shouldAddDefaultLabels() { .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 7f8b5d79953..58459c2d807 100644 --- a/core/src/test/java/org/testcontainers/junit/BaseDockerComposeTest.java +++ b/core/src/test/java/org/testcontainers/junit/BaseDockerComposeTest.java @@ -1,11 +1,10 @@ package org.testcontainers.junit; import com.github.dockerjava.api.model.Network; -import org.junit.After; -import org.junit.Assume; -import org.junit.Before; -import org.junit.BeforeClass; -import org.junit.Test; +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; @@ -15,9 +14,8 @@ import java.util.List; 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,50 +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)); + 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)); + 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()); + 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 index b0ed90d9c9e..1acac36e6ad 100644 --- a/core/src/test/java/org/testcontainers/junit/CopyFileToContainerTest.java +++ b/core/src/test/java/org/testcontainers/junit/CopyFileToContainerTest.java @@ -1,77 +1,97 @@ package org.testcontainers.junit; -import org.junit.Test; +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.rnorth.visibleassertions.VisibleAssertions.assertFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.testcontainers.TestImages.TINY_IMAGE; +import static org.assertj.core.api.Assertions.assertThat; + +class CopyFileToContainerTest { + + private static String destinationOnHost; + + private static String directoryInContainer = "/tmp/mappable-resource/"; -public class CopyFileToContainerTest { - private static String containerPath = "/tmp/mappable-resource/"; private static String fileName = "test-resource.txt"; + @BeforeEach + public void setup() throws IOException { + destinationOnHost = File.createTempFile("testcontainers", null).getAbsolutePath(); + } + @Test - public void checkFileCopied() throws IOException, InterruptedException { + void checkFileCopied() throws IOException, InterruptedException { try ( - GenericContainer container = new GenericContainer<>(TINY_IMAGE) + // copyToContainer { + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) .withCommand("sleep", "3000") - .withCopyFileToContainer(MountableFile.forClasspathResource("/mappable-resource/"), containerPath) + .withCopyFileToContainer( + MountableFile.forClasspathResource("/mappable-resource/"), + directoryInContainer + ) + // } ) { container.start(); - String filesList = container.execInContainer("ls", "/tmp/mappable-resource").getStdout(); - assertTrue("file list contains the file", filesList.contains(fileName)); + 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 - public void shouldUseCopyForReadOnlyClasspathResources() throws Exception { + void shouldUseCopyForReadOnlyClasspathResources() throws Exception { try ( - GenericContainer container = new GenericContainer<>(TINY_IMAGE) + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) .withCommand("sleep", "3000") - .withClasspathResourceMapping("/mappable-resource/", containerPath, BindMode.READ_ONLY) + .withClasspathResourceMapping("/mappable-resource/", directoryInContainer, BindMode.READ_ONLY) ) { container.start(); String filesList = container.execInContainer("ls", "/tmp/mappable-resource").getStdout(); - assertTrue("file list contains the file", filesList.contains(fileName)); + assertThat(filesList).as("file list contains the file").contains(fileName); } } @Test - public void shouldUseCopyOnlyWithReadOnlyClasspathResources() { + void shouldUseCopyOnlyWithReadOnlyClasspathResources() { String resource = "/test_copy_to_container.txt"; - GenericContainer container = new GenericContainer<>(TINY_IMAGE) + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) .withClasspathResourceMapping(resource, "/readOnly", BindMode.READ_ONLY) - .withClasspathResourceMapping(resource, "/readOnlyNoSelinux", BindMode.READ_ONLY) - .withClasspathResourceMapping(resource, "/readOnlyShared", BindMode.READ_ONLY, SelinuxContext.SHARED) .withClasspathResourceMapping(resource, "/readWrite", BindMode.READ_WRITE); Map copyMap = container.getCopyToFileContainerPathMap(); - assertTrue("uses copy for read-only", copyMap.containsValue("/readOnly")); - assertTrue("uses copy for read-only and no Selinux", copyMap.containsValue("/readOnlyNoSelinux")); - - assertFalse("uses mount for read-only with Selinux", copyMap.containsValue("/readOnlyShared")); - assertFalse("uses mount for read-write", copyMap.containsValue("/readWrite")); + 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 - public void shouldCreateFoldersStructureWithCopy() throws Exception { + void shouldCreateFoldersStructureWithCopy() throws Exception { String resource = "/test_copy_to_container.txt"; try ( - GenericContainer container = new GenericContainer<>(TINY_IMAGE) + 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(); - assertTrue("file list contains the file", filesList.contains("file")); + 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 index bd82c58a7f7..cbb305c30f8 100644 --- a/core/src/test/java/org/testcontainers/junit/DependenciesTest.java +++ b/core/src/test/java/org/testcontainers/junit/DependenciesTest.java @@ -1,8 +1,8 @@ package org.testcontainers.junit; import lombok.Getter; -import org.junit.Test; -import org.rnorth.visibleassertions.VisibleAssertions; +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; @@ -16,49 +16,49 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -import static org.testcontainers.TestImages.TINY_IMAGE; +import static org.assertj.core.api.Assertions.assertThat; -public class DependenciesTest { +class DependenciesTest { @Test - public void shouldWorkWithSimpleDependency() { + void shouldWorkWithSimpleDependency() { InvocationCountingStartable startable = new InvocationCountingStartable(); try ( - GenericContainer container = new GenericContainer<>(TINY_IMAGE) + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) .dependsOn(startable) ) { container.start(); } - VisibleAssertions.assertEquals("Started once", 1, startable.getStartInvocationCount().intValue()); - VisibleAssertions.assertEquals("Does not trigger .stop()", 0, startable.getStopInvocationCount().intValue()); + assertThat(startable.getStartInvocationCount().intValue()).as("Started once").isEqualTo(1); + assertThat(startable.getStopInvocationCount().intValue()).as("Does not trigger .stop()").isZero(); } @Test - public void shouldWorkWithMutlipleDependencies() { + void shouldWorkWithMultipleDependencies() { InvocationCountingStartable startable1 = new InvocationCountingStartable(); InvocationCountingStartable startable2 = new InvocationCountingStartable(); try ( - GenericContainer container = new GenericContainer<>(TINY_IMAGE) + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) .dependsOn(startable1, startable2) ) { container.start(); } - VisibleAssertions.assertEquals("Startable1 started once", 1, startable1.getStartInvocationCount().intValue()); - VisibleAssertions.assertEquals("Startable2 started once", 1, startable2.getStartInvocationCount().intValue()); + assertThat(startable1.getStartInvocationCount().intValue()).as("Startable1 started once").isEqualTo(1); + assertThat(startable2.getStartInvocationCount().intValue()).as("Startable2 started once").isEqualTo(1); } @Test - public void shouldStartEveryTime() { + void shouldStartEveryTime() { InvocationCountingStartable startable = new InvocationCountingStartable(); try ( - GenericContainer container = new GenericContainer<>(TINY_IMAGE) + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) .dependsOn(startable) ) { @@ -71,12 +71,12 @@ public void shouldStartEveryTime() { container.start(); } - VisibleAssertions.assertEquals("Started multiple times", 3, startable.getStartInvocationCount().intValue()); - VisibleAssertions.assertEquals("Does not trigger .stop()", 0, startable.getStopInvocationCount().intValue()); + assertThat(startable.getStartInvocationCount().intValue()).as("Started multiple times").isEqualTo(3); + assertThat(startable.getStopInvocationCount().intValue()).as("Does not trigger .stop()").isZero(); } @Test - public void shouldStartTransitiveDependencies() { + void shouldStartTransitiveDependencies() { InvocationCountingStartable transitiveOfTransitiveStartable = new InvocationCountingStartable(); InvocationCountingStartable transitiveStartable = new InvocationCountingStartable(); transitiveStartable.getDependencies().add(transitiveOfTransitiveStartable); @@ -85,7 +85,7 @@ public void shouldStartTransitiveDependencies() { startable.getDependencies().add(transitiveStartable); try ( - GenericContainer container = new GenericContainer<>(TINY_IMAGE) + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) .dependsOn(startable) ) { @@ -93,13 +93,15 @@ public void shouldStartTransitiveDependencies() { container.stop(); } - VisibleAssertions.assertEquals("Root started", 1, startable.getStartInvocationCount().intValue()); - VisibleAssertions.assertEquals("Transitive started", 1, transitiveStartable.getStartInvocationCount().intValue()); - VisibleAssertions.assertEquals("Transitive of transitive started", 1, transitiveOfTransitiveStartable.getStartInvocationCount().intValue()); + 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 - public void shouldHandleDiamondDependencies() throws Exception { + void shouldHandleDiamondDependencies() throws Exception { InvocationCountingStartable a = new InvocationCountingStartable(); InvocationCountingStartable b = new InvocationCountingStartable(); InvocationCountingStartable c = new InvocationCountingStartable(); @@ -115,15 +117,16 @@ public void shouldHandleDiamondDependencies() throws Exception { Startables.deepStart(Stream.of(d)).get(1, TimeUnit.SECONDS); - VisibleAssertions.assertEquals("A started", 1, a.getStartInvocationCount().intValue()); - VisibleAssertions.assertEquals("B started", 1, b.getStartInvocationCount().intValue()); - VisibleAssertions.assertEquals("C started", 1, c.getStartInvocationCount().intValue()); - VisibleAssertions.assertEquals("D started", 1, d.getStartInvocationCount().intValue()); + 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 - public void shouldHandleParallelStream() throws Exception { - List startables = Stream.generate(InvocationCountingStartable::new) + void shouldHandleParallelStream() throws Exception { + List startables = Stream + .generate(InvocationCountingStartable::new) .limit(10) .collect(Collectors.toList()); 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 53d90872332..2bc855cacb2 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposeContainerTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerTest.java @@ -1,56 +1,108 @@ package org.testcontainers.junit; -import org.junit.Rule; -import org.junit.Test; +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 java.lang.String.format; -import static java.util.Collections.singletonList; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertNotNull; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +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 { - @Rule - public DockerComposeContainer environment = new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) + @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); + DockerComposeContainerTest() { + environment.start(); + } + @Override protected DockerComposeContainer getEnvironment() { return environment; } @Test - public void testGetServicePort() { + void testGetServicePort() { int serviceWithInstancePort = environment.getServicePort("redis_1", REDIS_PORT); - assertNotNull("Port is set for service with instance number", serviceWithInstancePort); + assertThat(serviceWithInstancePort).as("Port is set for service with instance number").isNotNull(); int serviceWithoutInstancePort = environment.getServicePort("redis", REDIS_PORT); - assertNotNull("Port is set for service with instance number", serviceWithoutInstancePort); - assertEquals("Service ports are the same", serviceWithInstancePort, serviceWithoutInstancePort); + assertThat(serviceWithoutInstancePort).as("Port is set for service with instance number").isNotNull(); + assertThat(serviceWithoutInstancePort).as("Service ports are the same").isEqualTo(serviceWithInstancePort); } @Test - public void shouldRetrieveContainerByServiceName() { + void shouldRetrieveContainerByServiceName() { String existingServiceName = "db_1"; Optional result = environment.getContainerByServiceName(existingServiceName); - assertTrue(format("Container should be found by service name %s", existingServiceName), result.isPresent()); - assertEquals("Mapped port for result container was wrong, probably wrong container found", result.get().getExposedPorts(), singletonList(3306)); + + 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 - public void shouldReturnEmptyResultOnNoneExistingService() { + 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); - assertFalse(format("No container should be found under service name %s", notExistingServiceName), result.isPresent()); + 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 index 1b1efc878af..f0cbe11cbb9 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithBuildTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithBuildTest.java @@ -1,83 +1,97 @@ package org.testcontainers.junit; import com.github.dockerjava.api.model.Container; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; +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.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.testcontainers.containers.DockerComposeContainer.RemoveImages.ALL; -import static org.testcontainers.containers.DockerComposeContainer.RemoveImages.LOCAL; +import static org.assertj.core.api.Assertions.assertThat; -@RunWith(Parameterized.class) -public class DockerComposeContainerWithBuildTest { +class DockerComposeContainerWithBuildTest { - public DockerComposeContainerWithBuildTest(final DockerComposeContainer.RemoveImages removeMode, - final boolean shouldBuiltImageBePresentAfterRunning, - final boolean shouldPulledImageBePresentAfterRunning) { - this.removeMode = removeMode; - this.shouldBuiltImageBePresentAfterRunning = shouldBuiltImageBePresentAfterRunning; - this.shouldPulledImageBePresentAfterRunning = shouldPulledImageBePresentAfterRunning; + 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) + ); } - public final DockerComposeContainer.RemoveImages removeMode; - public final boolean shouldBuiltImageBePresentAfterRunning; - public final boolean shouldPulledImageBePresentAfterRunning; - - @Parameterized.Parameters(name = "removeMode = {0}") - public static Object[][] params() { - return new Object[][]{ - {null, true, true}, - {LOCAL, false, true}, - {ALL, false, false}, - }; - } - - @Test - public void performTest() { + @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<>(composeFile) - .withExposedService("customredis", 6379) - .withBuild(true) - .withRemoveImages(removeMode)) { - + 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()); - assertEquals("the built image is present while running", true, isBuiltImagePresentWhileRunning); + assertThat(isBuiltImagePresentWhileRunning).as("the built image is present while running").isEqualTo(true); pulledImageName.set(imageNameForRunningContainer("_normalredis_1")); final boolean isPulledImagePresentWhileRunning = isImagePresent(pulledImageName.get()); - assertEquals("the pulled image is present while running", true, isPulledImagePresentWhileRunning); + assertThat(isPulledImagePresentWhileRunning) + .as("the pulled image is present while running") + .isEqualTo(true); } - Unreliables.retryUntilSuccess(10, TimeUnit.SECONDS, () -> { - final boolean isBuiltImagePresentAfterRunning = isImagePresent(builtImageName.get()); - assertEquals("the built image is not present after running", shouldBuiltImageBePresentAfterRunning, isBuiltImagePresentAfterRunning); - return null; - }); - - Unreliables.retryUntilSuccess(10, TimeUnit.SECONDS, () -> { - final boolean isPulledImagePresentAfterRunning = isImagePresent(pulledImageName.get()); - assertEquals("the pulled image is present after running", shouldPulledImageBePresentAfterRunning, isPulledImagePresentAfterRunning); - return null; - }); + 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() + return DockerClientFactory + .instance() + .client() + .listContainersCmd() + .exec() + .stream() .filter(it -> Stream.of(it.getNames()).anyMatch(name -> name.endsWith(containerNameSuffix))) .findFirst() .map(Container::getImage) @@ -85,7 +99,13 @@ private String imageNameForRunningContainer(final String containerNameSuffix) { } private boolean isImagePresent(final String imageName) { - return DockerClientFactory.instance().client().listImagesCmd().withImageNameFilter(imageName).exec().stream() + 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 index 375c9baa070..84508326522 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithOptionsTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeContainerWithOptionsTest.java @@ -1,62 +1,85 @@ package org.testcontainers.junit; import com.google.common.collect.ImmutableSet; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; +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. */ -@RunWith(Parameterized.class) -public class DockerComposeContainerWithOptionsTest { - - public DockerComposeContainerWithOptionsTest(final File composeFile, final boolean local, final Set options, final boolean expectError) { - this.composeFile = composeFile; - this.local = local; - this.options = options; - this.expectError = expectError; - } - - private final File composeFile; - private final boolean local; - - private final Set options; - private final boolean expectError; - - @Parameterized.Parameters(name = "docker-compose test [compose file: {0}, local: {1}, options: {2}, expected result: {3}]") +class DockerComposeContainerWithOptionsTest { - public static Object[][] params() { - return new Object[][]{ - // Test the happy day case. THe compatibility option should be accepted by docker-compose. - {new File("src/test/resources/compose-options-test/with-deploy-block.yml"), false, ImmutableSet.of("--compatibility"), false}, + 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. - {new File("src/test/resources/compose-options-test/with-deploy-block.yml"), false, ImmutableSet.of(""), false}, + 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. - {new File("src/test/resources/compose-options-test/with-deploy-block.yml"), false, ImmutableSet.of("--bad-option"), true}, + Arguments.of( + new File("src/test/resources/compose-options-test/with-deploy-block.yml"), + false, + ImmutableSet.of("--bad-option"), + true + ), // Local compose - {new File("src/test/resources/compose-options-test/with-deploy-block.yml"), true, ImmutableSet.of("--compatibility"), false}, - }; + Arguments.of( + new File("src/test/resources/compose-options-test/with-deploy-block.yml"), + true, + ImmutableSet.of("--compatibility"), + false + ) + ); } - @Test - public void performTest() { + @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"); - try (DockerComposeContainer environment = new DockerComposeContainer<>(composeFile) - .withOptions(options.stream().toArray(String[]::new)) - .withLocalCompose(local)) { + 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/DockerComposeErrorHandlingTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeErrorHandlingTest.java index b8e26ffd3f1..907011329ec 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposeErrorHandlingTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeErrorHandlingTest.java @@ -1,20 +1,28 @@ package org.testcontainers.junit; -import org.junit.Test; -import org.rnorth.visibleassertions.VisibleAssertions; +import org.junit.jupiter.api.Test; import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.DockerImageName; import java.io.File; -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() { - VisibleAssertions.assertThrows("starting with an invalid docker-compose file throws an exception", - IllegalArgumentException.class, - () -> { - DockerComposeContainer environment = new DockerComposeContainer(new File("src/test/resources/invalid-compose.yml")) - .withExposedService("something", 123); - }); + 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); + }) + ) + .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 index 890fa34b0f8..1853a2d4ff1 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposeLocalImageTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeLocalImageTest.java @@ -2,19 +2,23 @@ import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.core.command.PullImageResultCallback; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.utility.DockerImageName; import java.io.File; -public class DockerComposeLocalImageTest { +class DockerComposeLocalImageTest { @Test - public void usesLocalImageEvenWhenPullFails() throws InterruptedException { - tagImage("redis:4.0.10", "redis-local", "latest"); + void usesLocalImageEvenWhenPullFails() throws InterruptedException { + tagImage("redis:6-alpine", "redis-local", "latest"); - DockerComposeContainer composeContainer = new DockerComposeContainer(new File("src/test/resources/local-compose-test.yml")) + 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(); } diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeLogConsumerTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeLogConsumerTest.java index 6cd90d3cd9c..7f63bdce9fd 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposeLogConsumerTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeLogConsumerTest.java @@ -1,30 +1,41 @@ package org.testcontainers.junit; -import org.junit.Test; -import org.junit.runner.Description; +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; -import static org.testcontainers.containers.output.OutputFrame.OutputType.STDOUT; - -public class DockerComposeLogConsumerTest { +class DockerComposeLogConsumerTest { @Test - public void testLogConsumer() throws TimeoutException { + void testLogConsumer() throws TimeoutException { WaitingConsumer logConsumer = new WaitingConsumer(); - DockerComposeContainer environment = new DockerComposeContainer(new File("src/test/resources/v2-compose-test.yml")) + 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.starting(Description.EMPTY); - logConsumer.waitUntil(frame -> frame.getType() == STDOUT && frame.getUtf8String().contains("Ready to accept connections"), 5, TimeUnit.SECONDS); + environment.start(); + logConsumer.waitUntil( + frame -> { + return ( + frame.getType() == OutputType.STDOUT && + frame.getUtf8String().contains("Ready to accept connections") + ); + }, + 5, + TimeUnit.SECONDS + ); } finally { - environment.finished(Description.EMPTY); + environment.stop(); } } } diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposePassthroughTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposePassthroughTest.java index cdeea00d201..a4f2707c583 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposePassthroughTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposePassthroughTest.java @@ -1,54 +1,51 @@ package org.testcontainers.junit; -import org.junit.Assume; -import org.junit.BeforeClass; -import org.junit.Rule; -import org.junit.Test; +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.File; import java.util.Arrays; import java.util.Objects; -import static org.hamcrest.CoreMatchers.hasItem; -import static org.rnorth.visibleassertions.VisibleAssertions.assertNotNull; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThat; +import static org.assertj.core.api.Assertions.assertThat; /** * Created by rnorth on 11/06/2016. */ -public class DockerComposePassthroughTest { - - private final TestWaitStrategy waitStrategy = new TestWaitStrategy(); - - @BeforeClass - public static void checkVersion() { - Assume.assumeTrue(TestEnvironment.dockerApiAtLeast("1.22")); - } - - @Rule - public DockerComposeContainer compose = - new DockerComposeContainer(new File("src/test/resources/v2-compose-test-passthrough.yml")) - .withEnv("foo", "bar") - .withExposedService("alpine_1", 3000, waitStrategy); - +class DockerComposePassthroughTest { @Test - public void testContainerInstanceProperties() { - final ContainerState container = waitStrategy.getContainer(); - - //check environment variable was set - assertThat("Environment variable set correctly", Arrays.asList(Objects.requireNonNull(container.getContainerInfo() - .getConfig().getEnv())), hasItem("bar=bar")); - - //check other container properties - assertNotNull("Container id is not null", container.getContainerId()); - assertNotNull("Port mapped", container.getMappedPort(3000)); - assertThat("Exposed Ports", container.getExposedPorts(), hasItem(3000)); - + 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); + } } /* diff --git a/core/src/test/java/org/testcontainers/junit/DockerComposeServiceTest.java b/core/src/test/java/org/testcontainers/junit/DockerComposeServiceTest.java index ec8c50d42ab..c1c295ef238 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposeServiceTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeServiceTest.java @@ -1,33 +1,46 @@ package org.testcontainers.junit; -import org.junit.Rule; -import org.junit.Test; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; import org.testcontainers.containers.DockerComposeContainer; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertNotNull; +import org.testcontainers.utility.DockerImageName; import java.io.File; -public class DockerComposeServiceTest extends BaseDockerComposeTest { +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Disabled +class DockerComposeServiceTest extends BaseDockerComposeTest { - @Rule - public DockerComposeContainer environment = new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) - .withServices("redis") - .withExposedService("redis_1", REDIS_PORT); + @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(expected = IllegalArgumentException.class) - public void testDbIsNotStarting() { - environment.getServicePort("db_1", 10001); + @Test + void testDbIsNotStarting() { + assertThatThrownBy(() -> { + environment.getServicePort("db_1", 10001); + }) + .isInstanceOf(IllegalArgumentException.class); } @Test - public void testRedisIsStarting() { - assertNotNull("Redis server started", environment.getServicePort("redis_1", REDIS_PORT)); + 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 index 32465010700..6f56ef9c24a 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerComposeWaitStrategyTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerComposeWaitStrategyTest.java @@ -1,88 +1,99 @@ package org.testcontainers.junit; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.catchThrowable; - -import java.io.File; -import java.time.Duration; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.Description; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import org.rnorth.visibleassertions.VisibleAssertions; import org.testcontainers.containers.DockerComposeContainer; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.utility.DockerImageName; -public class DockerComposeWaitStrategyTest { +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; - @Before + @BeforeEach public final void setUp() { - environment = new DockerComposeContainer<>( - new File("src/test/resources/compose-test.yml")); + environment = + new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:1.29.2"), + new File("src/test/resources/compose-test.yml") + ); } - @After + @AfterEach public final void cleanUp() { environment.stop(); } @Test - public void testWaitOnListeningPort() { - environment - .withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort()); + void testWaitOnListeningPort() { + environment.withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort()); try { - environment.starting(Description.createTestDescription(Object.class, "name")); - VisibleAssertions.pass("Docker compose should start after waiting for listening port"); + environment.start(); } catch (RuntimeException e) { - VisibleAssertions.fail("Docker compose should start after waiting for listening port with failed with: " + e); + fail("Docker compose should start after waiting for listening port with failed with: " + e); } } @Test - public void testWaitOnMultipleStrategiesPassing() { + 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.starting(Description.createTestDescription(Object.class, "name")); - VisibleAssertions.pass("Docker compose should start after waiting for listening port"); + environment.start(); } catch (RuntimeException e) { - VisibleAssertions.fail("Docker compose should start after waiting for listening port with failed with: " + e); + fail("Docker compose should start after waiting for listening port with failed with: " + e); } } @Test - public void testWaitingFails() { - environment - .withExposedService("redis_1", REDIS_PORT, Wait.forHttp("/test").withStartupTimeout(Duration.ofSeconds(10))); - VisibleAssertions.assertThrows("waiting on an invalid http path times out", - RuntimeException.class, - () -> environment.starting(Description.createTestDescription(Object.class, "name"))); + 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 - public void testWaitOnOneOfMultipleStrategiesFailing() { + 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))) + .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); - VisibleAssertions.assertThrows("waiting on one failing strategy to time out", - RuntimeException.class, - () -> environment.starting(Description.createTestDescription(Object.class, "name"))); + assertThat(catchThrowable(() -> environment.start())) + .as("waiting on one failing strategy to time out") + .isInstanceOf(RuntimeException.class); } @Test - public void testWaitingForNonexistentServices() { + void testWaitingForNonexistentServices() { String existentServiceName = "db_1"; String nonexistentServiceName1 = "some_nonexistent_service_1"; String nonexistentServiceName2 = "some_nonexistent_service_2"; @@ -93,14 +104,11 @@ public void testWaitingForNonexistentServices() { .waitingFor(nonexistentServiceName1, someWaitStrategy) .waitingFor(nonexistentServiceName2, someWaitStrategy); - Throwable thrownWhenRequestedToWaitForNonexistentService = - catchThrowable(environment::start); + Throwable thrownWhenRequestedToWaitForNonexistentService = catchThrowable(environment::start); assertThat(thrownWhenRequestedToWaitForNonexistentService) .isInstanceOf(IllegalStateException.class) - .hasMessageContaining( - nonexistentServiceName1, - nonexistentServiceName2) + .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 59c2a63127c..c35809f74e9 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerNetworkModeTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerNetworkModeTest.java @@ -2,25 +2,24 @@ import com.github.dockerjava.api.model.NetworkSettings; import lombok.extern.slf4j.Slf4j; -import org.junit.Test; +import org.junit.jupiter.api.Test; +import org.testcontainers.TestImages; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.testcontainers.TestImages.TINY_IMAGE; +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. */ @Slf4j -public class DockerNetworkModeTest { +class DockerNetworkModeTest { @Test - public void testNoNetworkContainer() { + void testNoNetworkContainer() { try ( - GenericContainer container = new GenericContainer<>(TINY_IMAGE) + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) .withCommand("true") .withNetworkMode("none") @@ -28,15 +27,15 @@ public void testNoNetworkContainer() { container.start(); NetworkSettings networkSettings = container.getContainerInfo().getNetworkSettings(); - assertEquals("only one network is set", 1, networkSettings.getNetworks().size()); - assertTrue("network is 'none'", networkSettings.getNetworks().containsKey("none")); + assertThat(networkSettings.getNetworks()).as("only one network is set").hasSize(1); + assertThat(networkSettings.getNetworks()).as("network is 'none'").containsKey("none"); } } @Test - public void testHostNetworkContainer() { + void testHostNetworkContainer() { try ( - GenericContainer container = new GenericContainer<>(TINY_IMAGE) + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) .withCommand("true") .withNetworkMode("host") @@ -44,8 +43,8 @@ public void testHostNetworkContainer() { container.start(); NetworkSettings networkSettings = container.getContainerInfo().getNetworkSettings(); - assertEquals("only one network is set", 1, networkSettings.getNetworks().size()); - assertTrue("network is 'host'", networkSettings.getNetworks().containsKey("host")); + 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 6ad7c3ea26e..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 { + 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 7390b21fce2..9f85af8d209 100644 --- a/core/src/test/java/org/testcontainers/junit/DockerfileTest.java +++ b/core/src/test/java/org/testcontainers/junit/DockerfileTest.java @@ -1,10 +1,11 @@ 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; @@ -15,34 +16,31 @@ 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)); @@ -54,30 +52,32 @@ 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; @@ -97,23 +97,27 @@ public String getDescription() { 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(); } @@ -124,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 index beca4a8a49f..f2c8117a905 100644 --- a/core/src/test/java/org/testcontainers/junit/FileOperationsTest.java +++ b/core/src/test/java/org/testcontainers/junit/FileOperationsTest.java @@ -3,128 +3,167 @@ import com.github.dockerjava.api.exception.NotFoundException; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; -import org.assertj.core.api.Assertions; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; +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.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.testcontainers.TestImages.ALPINE_IMAGE; +import static org.assertj.core.api.Assertions.assertThatThrownBy; -public class FileOperationsTest { +class FileOperationsTest { - @Rule - public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @TempDir + public Path temporaryFolder; @Test - public void copyFileToContainerFileTest() throws Exception { + void copyFileToContainerFileTest() throws Exception { try ( - GenericContainer alpineCopyToContainer = new GenericContainer(ALPINE_IMAGE) + 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.getRoot().getAbsolutePath() + "/test_copy_to_container.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()); - assertTrue("Files aren't same ", FileUtils.contentEquals(expectedFile, actualFile)); + assertThat(FileUtils.contentEquals(expectedFile, actualFile)).as("Files aren't same ").isTrue(); } } @Test - public void copyFileToContainerFolderTest() throws Exception { + void copyLargeFilesToContainer() throws Exception { + File tempFile = temporaryFolder.resolve("large-file").toFile(); try ( - GenericContainer alpineCopyToContainer = new GenericContainer(ALPINE_IMAGE) + 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.getRoot().getAbsolutePath() + "/test_copy_to_container.txt"); + 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()); - assertTrue("Files aren't same ", FileUtils.contentEquals(expectedFile, actualFile)); + assertThat(FileUtils.contentEquals(expectedFile, actualFile)).as("Files aren't same ").isTrue(); } } @Test - public void copyFolderToContainerFolderTest() throws Exception { + void copyFolderToContainerFolderTest() throws Exception { try ( - GenericContainer alpineCopyToContainer = new GenericContainer(ALPINE_IMAGE) + 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.getRoot().getAbsolutePath() + "/test_copy_to_container.txt"); + 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"); - assertTrue("Files aren't same ", FileUtils.contentEquals(expectedFile, actualFile)); + assertThat(FileUtils.contentEquals(expectedFile, actualFile)).as("Files aren't same ").isTrue(); } } - @Test(expected = NotFoundException.class) - public void copyFromContainerShouldFailBecauseNoFileTest() throws NotFoundException { - try ( - GenericContainer alpineCopyToContainer = new GenericContainer(ALPINE_IMAGE) - .withCommand("top") - ) { - alpineCopyToContainer.start(); - alpineCopyToContainer.copyFileFromContainer("/home/test.txt", "src/test/resources/copy-from/test.txt"); - } + @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 - public void shouldCopyFileFromContainerTest() throws IOException { + void shouldCopyFileFromContainerTest() throws IOException { try ( - GenericContainer alpineCopyToContainer = new GenericContainer(ALPINE_IMAGE) + 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.getRoot().getAbsolutePath() + "/test_copy_from_container.txt"); + 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()); - assertTrue("Files aren't same ", FileUtils.contentEquals(expectedFile, actualFile)); + assertThat(FileUtils.contentEquals(expectedFile, actualFile)).as("Files aren't same ").isTrue(); } } @Test - public void copyFileOperationsShouldFailWhenNotStartedTest() { - try (GenericContainer container = new GenericContainer<>(ALPINE_IMAGE).withCommand("top")) { - Assertions - .assertThatThrownBy(() -> { + 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"); - Assertions - .assertThatThrownBy(() -> { - container.copyFileFromContainer( - "/home/test_copy_to_container.txt", - IOUtils::toByteArray - ); + assertThatThrownBy(() -> { + container.copyFileFromContainer("/home/test_copy_to_container.txt", IOUtils::toByteArray); }) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("can only be used when the Container is created"); @@ -132,20 +171,19 @@ public void copyFileOperationsShouldFailWhenNotStartedTest() { } @Test - public void shouldCopyFileFromExitedContainerTest() throws IOException { + void shouldCopyFileFromExitedContainerTest() { try ( - GenericContainer container = new GenericContainer<>(ALPINE_IMAGE) + 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()) - .isEqualTo(0); + assertThat( + container.getDockerClient().waitContainerCmd(container.getContainerId()).start().awaitStatusCode() + ) + .isZero(); - container.copyFileFromContainer( - "/home/file_in_container.txt", - IOUtils::toByteArray - ); + container.copyFileFromContainer("/home/file_in_container.txt", IOUtils::toByteArray); container.copyFileToContainer( MountableFile.forClasspathResource("test_copy_to_container.txt"), diff --git a/core/src/test/java/org/testcontainers/junit/FixedHostPortContainerTest.java b/core/src/test/java/org/testcontainers/junit/FixedHostPortContainerTest.java index 34c039b4fd3..38f1f5c9a6f 100644 --- a/core/src/test/java/org/testcontainers/junit/FixedHostPortContainerTest.java +++ b/core/src/test/java/org/testcontainers/junit/FixedHostPortContainerTest.java @@ -1,8 +1,8 @@ package org.testcontainers.junit; -import com.google.common.util.concurrent.Uninterruptibles; -import org.junit.Test; -import org.rnorth.ducttape.unreliables.Unreliables; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.testcontainers.TestImages; import org.testcontainers.containers.FixedHostPortGenericContainer; import org.testcontainers.containers.GenericContainer; @@ -10,11 +10,9 @@ import java.io.IOException; import java.io.InputStreamReader; import java.net.Socket; -import java.util.concurrent.TimeUnit; +import java.time.Duration; -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.testcontainers.TestImages.TINY_IMAGE; +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 @@ -22,9 +20,9 @@ * 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.2"; + private static final String TEST_IMAGE = "alpine:3.17"; /** * Default http server port (just something different from default) @@ -39,15 +37,18 @@ public class FixedHostPortContainerTest { /** * *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); + 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 final Integer unusedHostPort; try ( - final GenericContainer echoServer = new GenericContainer(TINY_IMAGE) + final GenericContainer echoServer = new GenericContainer(TestImages.TINY_IMAGE) .withExposedPorts(TEST_PORT) .withCommand("/bin/sh", "-c", HTTP_ECHO_CMD) ) { @@ -58,18 +59,19 @@ public void testFixedHostPortMapping() throws IOException { // 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) + // using workaround for port bind+expose + .withFixedExposedPort(unusedHostPort, TEST_PORT) + .withExposedPorts(TEST_PORT) + .withCommand("/bin/sh", "-c", HTTP_ECHO_CMD) ) { echoServer.start(); - assertThat("Port mapping does not seem to match given fixed port", - echoServer.getMappedPort(TEST_PORT), equalTo(unusedHostPort)); + assertThat(echoServer.getMappedPort(TEST_PORT)) + .as("Port mapping does not seem to match given fixed port") + .isEqualTo(unusedHostPort); - final String content = this.readResponse(echoServer, unusedHostPort); - assertThat("Returned echo from fixed port does not match expected", content, equalTo(TEST_RESPONSE)); + final String content = readResponse(echoServer, unusedHostPort); + assertThat(content).as("Returned echo from fixed port does not match expected").isEqualTo(TEST_RESPONSE); } } @@ -83,13 +85,11 @@ public void testFixedHostPortMapping() throws IOException { */ private String readResponse(GenericContainer container, Integer port) throws IOException { try ( - final BufferedReader reader = Unreliables.retryUntilSuccess(10, TimeUnit.SECONDS, - () -> { - Uninterruptibles.sleepUninterruptibly(1, TimeUnit.SECONDS); - final Socket socket = new Socket(container.getHost(), port); - return new BufferedReader(new InputStreamReader(socket.getInputStream())); - } - ) + 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 c13421029e0..5719f3e0268 100644 --- a/core/src/test/java/org/testcontainers/junit/GenericContainerRuleTest.java +++ b/core/src/test/java/org/testcontainers/junit/GenericContainerRuleTest.java @@ -14,18 +14,18 @@ import com.rabbitmq.client.Envelope; import org.apache.commons.io.FileUtils; import org.bson.Document; -import org.junit.Assume; -import org.junit.BeforeClass; -import org.junit.ClassRule; -import org.junit.Ignore; -import org.junit.Test; +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.BufferedReader; import java.io.File; @@ -37,45 +37,37 @@ 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 java.util.Collections.singletonMap; -import static org.hamcrest.CoreMatchers.equalTo; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThat; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThrows; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.testcontainers.TestImages.ALPINE_IMAGE; -import static org.testcontainers.TestImages.MONGODB_IMAGE; -import static org.testcontainers.TestImages.RABBITMQ_IMAGE; -import static org.testcontainers.TestImages.REDIS_IMAGE; -import static org.testcontainers.TestImages.TINY_IMAGE; -import static org.testcontainers.containers.BindMode.READ_ONLY; -import static org.testcontainers.containers.BindMode.READ_WRITE; -import static org.testcontainers.containers.SelinuxContext.SHARED; +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; + 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(); @@ -85,107 +77,112 @@ public static void setupContent() throws FileNotFoundException { /** * Redis */ - @ClassRule - public static GenericContainer redis = new GenericContainer<>(REDIS_IMAGE) - .withExposedPorts(REDIS_PORT); + public static GenericContainer redis = new GenericContainer<>(TestImages.REDIS_IMAGE) + .withExposedPorts(REDIS_PORT); /** * RabbitMQ */ - @ClassRule - public static GenericContainer rabbitMq = new GenericContainer<>(RABBITMQ_IMAGE) - .withExposedPorts(RABBITMQ_PORT); + public static GenericContainer rabbitMq = new GenericContainer<>(TestImages.RABBITMQ_IMAGE) + .withExposedPorts(RABBITMQ_PORT); + /** * MongoDB */ - @ClassRule - public static GenericContainer mongo = new GenericContainer<>(MONGODB_IMAGE) - .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_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"); + 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_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"); + 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. */ - @ClassRule - public static GenericContainer alpineClasspathResource = new GenericContainer<>(ALPINE_IMAGE) - .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 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 alpineClasspathResourceSelinux = new GenericContainer<>(ALPINE_IMAGE) - .withExposedPorts(80) - .withClasspathResourceMapping("mappable-resource/test-resource.txt", "/content.txt", READ_WRITE, SHARED) - .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_IMAGE) - .withExposedPorts(80) - .withExtraHost("somehost", "192.168.1.10") - .withCommand("/bin/sh", "-c", "while true; do cat /etc/hosts | nc -l -p 80; done"); + 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 testIsRunning() { - try ( - GenericContainer container = new GenericContainer<>(TINY_IMAGE) - .withCommand("top")) { - assertFalse("Container is not started and not running", container.isRunning()); + 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(); - assertTrue("Container is started and running", container.isRunning()); + assertThat(container.isRunning()).as("Container is started and running").isTrue(); } } @Test - public void withTmpFsTest() throws Exception { + void withTmpFsTest() throws Exception { try ( - GenericContainer container = new GenericContainer<>(TINY_IMAGE) + GenericContainer container = new GenericContainer<>(TestImages.TINY_IMAGE) .withCommand("top") - .withTmpFs(singletonMap("/testtmpfs", "rw")) + .withTmpFs(Collections.singletonMap("/testtmpfs", "rw")) ) { container.start(); // check file doesn't exist String path = "/testtmpfs/test.file"; Container.ExecResult execResult = container.execInContainer("ls", path); - assertEquals("tmpfs inside container works fine", execResult.getStderr(), - "ls: /testtmpfs/test.file: No such file or directory\n"); + 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); - assertEquals("tmpfs inside container works fine", execResult.getStdout(), path + "\n"); + assertThat(execResult.getStdout()).as("tmpfs inside container works fine").isEqualTo(path + "\n"); } } - @Test - public void simpleRabbitMqTest() throws IOException, TimeoutException { + void simpleRabbitMqTest() throws IOException, TimeoutException { ConnectionFactory factory = new ConnectionFactory(); factory.setHost(rabbitMq.getHost()); factory.setPort(rabbitMq.getMappedPort(RABBITMQ_PORT)); @@ -198,104 +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() { + 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 - public void customLabelTest() { - try (final GenericContainer alpineCustomLabel = new GenericContainer<>(ALPINE_IMAGE) - .withLabel("our.custom", "label") - .withCommand("top")) { - + 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(); - assertTrue("org.testcontainers label is present", labels.containsKey("org.testcontainers")); - assertTrue("our.custom label is present", labels.containsKey("our.custom")); - assertEquals("our.custom label value is label", labels.get("our.custom"), "label"); + 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 - public void exceptionThrownWhenTryingToOverrideTestcontainersLabels() { - assertThrows("When trying to overwrite an 'org.testcontainers' label, withLabel() throws an exception", - IllegalArgumentException.class, - () -> { - new GenericContainer<>(ALPINE_IMAGE) - .withLabel("org.testcontainers.foo", "false"); - } - ); + 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 customClasspathResourceMappingWithSelinuxTest() throws IOException { + void customClasspathResourceMappingWithSelinuxTest() throws IOException { String line = getReaderForContainerPort80(alpineClasspathResourceSelinux).readLine(); - assertEquals("Resource on the classpath can be mapped using calls to withClasspathResourceMappingSelinux", "FOOBAR", line); + assertThat(line) + .as("Resource on the classpath can be mapped using calls to withClasspathResourceMappingSelinux") + .isEqualTo("FOOBAR"); } @Test - public void exceptionThrownWhenMappedPortNotFound() { - assertThrows("When the requested port is not mapped, getMappedPort() throws an exception", - IllegalArgumentException.class, - () -> { - return redis.getMappedPort(666); - }); + 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)); @@ -303,54 +332,35 @@ protected static void writeStringToFile(File contentFolder, String filename, Str printStream.close(); } - @Test @Ignore //TODO investigate intermittent failures - public void failFastWhenContainerHaltsImmediately() { - - long startingTimeMs = System.currentTimeMillis(); - final GenericContainer failsImmediately = new GenericContainer<>(ALPINE_IMAGE) - .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 @@ -363,66 +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_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")) + 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); - 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())); - }); + Socket socket = new Socket(container.getHost(), container.getFirstMappedPort()); + return new BufferedReader(new InputStreamReader(socket.getInputStream())); + } + ); } @Test - public void addExposedPortAfterWithExposedPortsTest() { + void addExposedPortAfterWithExposedPortsTest() { redis.addExposedPort(8987); - assertThat("Both ports should be exposed", redis.getExposedPorts().size(), equalTo(2)); - assertTrue("withExposedPort should be exposed", redis.getExposedPorts().contains(REDIS_PORT)); - assertTrue("addExposedPort should be exposed", redis.getExposedPorts().contains(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); } @Test - public void addingExposedPortTwiceShouldNotFail() { + void addingExposedPortTwiceShouldNotFail() { redis.addExposedPort(8987); redis.addExposedPort(8987); - assertThat("Both ports should be exposed", redis.getExposedPorts().size(), equalTo(2)); // 2 ports = de-duplicated port 8897 and original port 6379 - assertTrue("withExposedPort should be exposed", redis.getExposedPorts().contains(REDIS_PORT)); - assertTrue("addExposedPort should be exposed", redis.getExposedPorts().contains(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 - public void sharedMemorySetTest() { - try (GenericContainer containerWithSharedMemory = new GenericContainer<>(TINY_IMAGE) - .withSharedMemorySize(42L * FileUtils.ONE_MB) - .withStartupCheckStrategy(new OneShotStartupCheckStrategy())) { - + 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(); - assertEquals("Shared memory not set on container", hostConfig.getShmSize(), 42L * FileUtils.ONE_MB); + 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 95047779551..2978777aae7 100644 --- a/core/src/test/java/org/testcontainers/junit/NonExistentImagePullTest.java +++ b/core/src/test/java/org/testcontainers/junit/NonExistentImagePullTest.java @@ -1,23 +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, () -> { - new GenericContainer<>(DockerImageName.parse("testcontainers/nonexistent:latest")).getDockerImageName(); - }); + @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 649a0b56fc4..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,73 +17,96 @@ import java.util.concurrent.TimeoutException; import java.util.function.Consumer; -import static org.rnorth.visibleassertions.VisibleAssertions.assertFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThrows; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.testcontainers.TestImages.ALPINE_IMAGE; -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_IMAGE) - .withCommand("ping -c 5 127.0.0.1"); +class OutputStreamTest { private static final Logger LOGGER = LoggerFactory.getLogger(OutputStreamTest.class); - @Test(timeout = 60_000L) - public void testFetchStdout() throws TimeoutException { - - WaitingConsumer consumer = new WaitingConsumer(); - - container.followOutput(consumer, STDOUT); + public GenericContainer container = new GenericContainer(TestImages.ALPINE_IMAGE) + .withCommand("ping -c 5 127.0.0.1"); - consumer.waitUntil(frame -> frame.getType() == STDOUT && frame.getUtf8String().contains("seq=2"), - 30, TimeUnit.SECONDS); + @BeforeEach + void setUp() { + container.start(); } - @Test(timeout = 60_000L) - public void testFetchStdoutWithTimeout() { + @AfterEach + void tearDown() { + container.stop(); + } + @Test + @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(timeout = 60_000L) - public void testFetchStdoutWithNoLimit() throws TimeoutException { + @Test + @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(timeout = 60_000L) - public void testLogConsumer() throws TimeoutException { - + @Test + @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(timeout = 60_000L) - public void testToStringConsumer() throws TimeoutException { - + @Test + @Timeout(value = 60) + void testToStringConsumer() throws TimeoutException { WaitingConsumer waitingConsumer = new WaitingConsumer(); ToStringConsumer toStringConsumer = new ToStringConsumer(); @@ -90,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 index b15f63f4158..a6dbfa06343 100644 --- a/core/src/test/java/org/testcontainers/junit/OutputStreamWithTTYTest.java +++ b/core/src/test/java/org/testcontainers/junit/OutputStreamWithTTYTest.java @@ -1,9 +1,11 @@ package org.testcontainers.junit; import lombok.extern.slf4j.Slf4j; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.Timeout; +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; @@ -15,67 +17,88 @@ import java.util.concurrent.TimeoutException; import java.util.function.Consumer; -import static org.rnorth.visibleassertions.VisibleAssertions.assertFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThrows; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.testcontainers.TestImages.ALPINE_IMAGE; -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; @Slf4j -public class OutputStreamWithTTYTest { +@Timeout(10) +class OutputStreamWithTTYTest { - @Rule - public GenericContainer container = new GenericContainer<>(ALPINE_IMAGE) + @AutoClose + public GenericContainer container = new GenericContainer<>(TestImages.ALPINE_IMAGE) .withCommand("ls -1") .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) .withCreateContainerCmdModifier(command -> command.withTty(true)); - @Rule - public Timeout globalTimeout = Timeout.seconds(10); + @BeforeEach + void setUp() { + container.start(); + } @Test - public void testFetchStdout() throws TimeoutException { + void testFetchStdout() 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("home"), 4, TimeUnit.SECONDS); + consumer.waitUntil( + frame -> { + return frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().contains("home"); + }, + 4, + TimeUnit.SECONDS + ); } @Test - public void testFetchStdoutWithTimeout() { + void testFetchStdoutWithTimeout() { WaitingConsumer consumer = new WaitingConsumer(); - container.followOutput(consumer, STDOUT); - - assertThrows("a TimeoutException should be thrown", TimeoutException.class, () -> { - consumer.waitUntil(frame -> frame.getType() == STDOUT && frame.getUtf8String().contains("qqq"), 1, TimeUnit.SECONDS); - return true; - }); + 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 - public void testFetchStdoutWithNoLimit() throws TimeoutException { + 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("home")); + consumer.waitUntil(frame -> { + return frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().contains("home"); + }); } @Test - public void testLogConsumer() throws TimeoutException { + void testLogConsumer() throws TimeoutException { WaitingConsumer waitingConsumer = new WaitingConsumer(); Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(log); Consumer composedConsumer = logConsumer.andThen(waitingConsumer); container.followOutput(composedConsumer); - waitingConsumer.waitUntil(frame -> frame.getType() == STDOUT && frame.getUtf8String().contains("home")); + waitingConsumer.waitUntil(frame -> { + return frame.getType() == OutputFrame.OutputType.STDOUT && frame.getUtf8String().contains("home"); + }); } @Test - public void testToStringConsumer() throws TimeoutException { + void testToStringConsumer() throws TimeoutException { WaitingConsumer waitingConsumer = new WaitingConsumer(); ToStringConsumer toStringConsumer = new ToStringConsumer(); @@ -85,8 +108,8 @@ public void testToStringConsumer() throws TimeoutException { waitingConsumer.waitUntilEnd(4, TimeUnit.SECONDS); String utf8String = toStringConsumer.toUtf8String(); - assertTrue("the expected first value was found", utf8String.contains("home")); - assertTrue("the expected last value was found", utf8String.contains("media")); - assertFalse("a non-expected value was found", utf8String.contains("qqq")); + 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 4ea261e68fd..0f98a35893b 100644 --- a/core/src/test/java/org/testcontainers/junit/ParameterizedDockerfileContainerTest.java +++ b/core/src/test/java/org/testcontainers/junit/ParameterizedDockerfileContainerTest.java @@ -1,53 +1,59 @@ package org.testcontainers.junit; -import org.junit.Rule; -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.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.testcontainers.containers.GenericContainer; import org.testcontainers.images.builder.ImageFromDockerfile; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import java.util.stream.Stream; + +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 { private final String expectedVersion; - @Rule public GenericContainer container; 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 = + 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; } - @Parameterized.Parameters(name = "{0}") - public static Object[][] data() { - return new Object[][] { - { "alpine:3.2", "3.2"}, - { "alpine:3.3", "3.3"}, - { "alpine:3.4", "3.4"}, - { "alpine:3.5", "3.5"}, - { "alpine:3.6", "3.6"} - }; + 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 Exception { + void simpleTest() throws Exception { final String release = container.execInContainer("cat", "/etc/alpine-release").getStdout(); - assertTrue("/etc/alpine-release starts with " + expectedVersion, - release.startsWith(expectedVersion)); + 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 b23ddf2ca1c..a31b38e06ed 100644 --- a/core/src/test/java/org/testcontainers/junit/WorkingDirectoryTest.java +++ b/core/src/test/java/org/testcontainers/junit/WorkingDirectoryTest.java @@ -1,31 +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.startupcheck.OneShotStartupCheckStrategy; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.testcontainers.TestImages.ALPINE_IMAGE; +import static org.assertj.core.api.Assertions.assertThat; /** * Created by rnorth on 26/07/2016. */ -public class WorkingDirectoryTest { +class WorkingDirectoryTest { - @ClassRule - public static GenericContainer container = new GenericContainer(ALPINE_IMAGE) - .withWorkingDirectory("/etc") - .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) - .withCommand("ls", "-al"); + public static GenericContainer container = new GenericContainer(TestImages.ALPINE_IMAGE) + .withWorkingDirectory("/etc") + .withStartupCheckStrategy(new OneShotStartupCheckStrategy()) + .withCommand("ls", "-al"); + + static { + container.start(); + } @Test - public void checkOutput() { + 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/strategy/AbstractWaitStrategyTest.java b/core/src/test/java/org/testcontainers/junit/wait/strategy/AbstractWaitStrategyTest.java index 480536a54ef..fa97bbc25c3 100644 --- a/core/src/test/java/org/testcontainers/junit/wait/strategy/AbstractWaitStrategyTest.java +++ b/core/src/test/java/org/testcontainers/junit/wait/strategy/AbstractWaitStrategyTest.java @@ -1,9 +1,8 @@ package org.testcontainers.junit.wait.strategy; import org.jetbrains.annotations.NotNull; -import org.junit.Before; +import org.junit.jupiter.api.BeforeEach; import org.rnorth.ducttape.RetryCountExceededException; -import org.rnorth.visibleassertions.VisibleAssertions; import org.testcontainers.TestImages; import org.testcontainers.containers.ContainerLaunchException; import org.testcontainers.containers.GenericContainer; @@ -12,14 +11,14 @@ import java.time.Duration; import java.util.concurrent.atomic.AtomicBoolean; -import static org.junit.Assert.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; /** * Common test methods for {@link WaitStrategy} implementations. - * - * @author Pete Cornish {@literal } */ public abstract class AbstractWaitStrategyTest { + static final long WAIT_TIMEOUT_MILLIS = 3000; /** @@ -37,7 +36,7 @@ public abstract class AbstractWaitStrategyTest { @NotNull protected abstract W buildWaitStrategy(final AtomicBoolean ready); - @Before + @BeforeEach public void setUp() { ready = new AtomicBoolean(false); } @@ -65,12 +64,16 @@ protected GenericContainer startContainerWithCommand(String shellCommand, Wai return startContainerWithCommand(shellCommand, waitStrategy, 8080); } - protected GenericContainer startContainerWithCommand(String shellCommand, WaitStrategy waitStrategy, Integer... ports) { + 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))); + .withExposedPorts(ports) + .withCommand("sh", "-c", shellCommand) + .waitingFor(waitStrategy.withStartupTimeout(Duration.ofMillis(WAIT_TIMEOUT_MILLIS))); } /** @@ -79,7 +82,9 @@ protected GenericContainer startContainerWithCommand(String shellCommand, Wai * @param shellCommand the shell command to execute */ protected void waitUntilReadyAndSucceed(String shellCommand) { - waitUntilReadyAndSucceed(startContainerWithCommand(shellCommand)); + try (GenericContainer container = startContainerWithCommand(shellCommand)) { + waitUntilReadyAndSucceed(container); + } } /** @@ -89,7 +94,9 @@ protected void waitUntilReadyAndSucceed(String shellCommand) { * @param shellCommand the shell command to execute */ protected void waitUntilReadyAndTimeout(String shellCommand) { - waitUntilReadyAndTimeout(startContainerWithCommand(shellCommand)); + try (GenericContainer container = startContainerWithCommand(shellCommand)) { + waitUntilReadyAndTimeout(container); + } } /** @@ -100,10 +107,9 @@ protected void waitUntilReadyAndTimeout(String shellCommand) { */ protected void waitUntilReadyAndTimeout(GenericContainer container) { // start() blocks until successful or timeout - VisibleAssertions.assertThrows("an exception is thrown when timeout occurs (" + WAIT_TIMEOUT_MILLIS + "ms)", - ContainerLaunchException.class, - container::start); - + assertThat(catchThrowable(container::start)) + .as("an exception is thrown when timeout occurs (" + WAIT_TIMEOUT_MILLIS + "ms)") + .isInstanceOf(ContainerLaunchException.class); } /** @@ -115,7 +121,8 @@ protected void waitUntilReadyAndSucceed(GenericContainer container) { // 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()); + 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 index 5c7d37a7da3..15b6aa74961 100644 --- a/core/src/test/java/org/testcontainers/junit/wait/strategy/HostPortWaitStrategyTest.java +++ b/core/src/test/java/org/testcontainers/junit/wait/strategy/HostPortWaitStrategyTest.java @@ -1,29 +1,46 @@ package org.testcontainers.junit.wait.strategy; -import org.junit.ClassRule; -import org.junit.Test; +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; -import static org.rnorth.visibleassertions.VisibleAssertions.pass; - /** * Test wait strategy with overloaded waitingFor methods. * Other implementations of WaitStrategy are tested through backwards compatible wait strategy tests */ -public class HostPortWaitStrategyTest { +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 { - @ClassRule - public static 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))); + 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 - public void testWaiting() { - pass("Container starts after waiting"); + @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 index c5a39a4538c..eb9169fd1ac 100644 --- a/core/src/test/java/org/testcontainers/junit/wait/strategy/HttpWaitStrategyTest.java +++ b/core/src/test/java/org/testcontainers/junit/wait/strategy/HttpWaitStrategyTest.java @@ -1,25 +1,25 @@ package org.testcontainers.junit.wait.strategy; -import static org.hamcrest.CoreMatchers.containsString; -import static org.junit.Assert.assertThat; - -import java.util.HashMap; +import org.assertj.core.api.Assertions; import org.jetbrains.annotations.NotNull; -import org.junit.Test; +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}. - * - * @author Pete Cornish {@literal } */ -public class HttpWaitStrategyTest extends AbstractWaitStrategyTest { +class HttpWaitStrategyTest extends AbstractWaitStrategyTest { + /** * newline sequence indicating end of the HTTP header. */ @@ -31,7 +31,7 @@ public class HttpWaitStrategyTest extends AbstractWaitStrategyTest headers = new HashMap<>(); headers.put("baz", "boo"); - GenericContainer container = startContainerWithCommand(createShellCommand("200 OK", GOOD_RESPONSE_BODY), - createHttpWaitStrategy(ready) - .withHeader("foo", "bar") - .withHeaders(headers) - ); - waitUntilReadyAndSucceed(container); + try ( + GenericContainer container = startContainerWithCommand( + createShellCommand("200 OK", GOOD_RESPONSE_BODY), + createHttpWaitStrategy(ready).withHeader("foo", "bar").withHeaders(headers) + ) + ) { + waitUntilReadyAndSucceed(container); - String logs = container.getLogs(); + String logs = container.getLogs(); - assertThat(logs, containsString("foo: bar")); - assertThat(logs, containsString("baz: boo")); + 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); + } } /** @@ -62,11 +80,15 @@ public void testWaitUntilReadyWithSuccessWithCustomHeaders() { * This 401 response is checked with a lambda using {@link HttpWaitStrategy#forStatusCodeMatching(Predicate)} */ @Test - public void testWaitUntilReadyWithUnauthorizedWithLambda() { - waitUntilReadyAndSucceed(startContainerWithCommand(createShellCommand("401 UNAUTHORIZED", GOOD_RESPONSE_BODY), - createHttpWaitStrategy(ready) - .forStatusCodeMatching(it -> it >= 200 && it < 300 || it == 401) - )); + void testWaitUntilReadyWithUnauthorizedWithLambda() { + try ( + GenericContainer container = startContainerWithCommand( + createShellCommand("401 UNAUTHORIZED", GOOD_RESPONSE_BODY), + createHttpWaitStrategy(ready).forStatusCodeMatching(it -> it >= 200 && it < 300 || it == 401) + ) + ) { + waitUntilReadyAndSucceed(container); + } } /** @@ -74,28 +96,35 @@ public void testWaitUntilReadyWithUnauthorizedWithLambda() { * This 401 response is checked with many status codes using {@link HttpWaitStrategy#forStatusCode(int)} */ @Test - public void testWaitUntilReadyWithManyStatusCodes() { - waitUntilReadyAndSucceed(startContainerWithCommand(createShellCommand("401 UNAUTHORIZED", GOOD_RESPONSE_BODY), - createHttpWaitStrategy(ready) - .forStatusCode(300) - .forStatusCode(401) - .forStatusCode(500) - )); + 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 with many status codes using {@link HttpWaitStrategy#forStatusCode(int)} + * This 401 response is checked with many status codes using {@link HttpWaitStrategy#forStatusCode(int)} * and a lambda using {@link HttpWaitStrategy#forStatusCodeMatching(Predicate)} */ @Test - public void testWaitUntilReadyWithManyStatusCodesAndLambda() { - waitUntilReadyAndSucceed(startContainerWithCommand(createShellCommand("401 UNAUTHORIZED", GOOD_RESPONSE_BODY), - createHttpWaitStrategy(ready) - .forStatusCode(300) - .forStatusCode(500) - .forStatusCodeMatching(it -> it == 401) - )); + void testWaitUntilReadyWithManyStatusCodesAndLambda() { + try ( + GenericContainer container = startContainerWithCommand( + createShellCommand("401 UNAUTHORIZED", GOOD_RESPONSE_BODY), + createHttpWaitStrategy(ready) + .forStatusCode(300) + .forStatusCode(500) + .forStatusCodeMatching(it -> it == 401) + ) + ) { + waitUntilReadyAndSucceed(container); + } } /** @@ -104,12 +133,15 @@ public void testWaitUntilReadyWithManyStatusCodesAndLambda() { * and {@link HttpWaitStrategy#forStatusCodeMatching(Predicate)} */ @Test - public void testWaitUntilReadyWithTimeoutAndWithManyStatusCodesAndLambda() { - waitUntilReadyAndTimeout(startContainerWithCommand(createShellCommand("401 UNAUTHORIZED", GOOD_RESPONSE_BODY), - createHttpWaitStrategy(ready) - .forStatusCode(300) - .forStatusCodeMatching(it -> it == 500) - )); + void testWaitUntilReadyWithTimeoutAndWithManyStatusCodesAndLambda() { + try ( + GenericContainer container = startContainerWithCommand( + createShellCommand("401 UNAUTHORIZED", GOOD_RESPONSE_BODY), + createHttpWaitStrategy(ready).forStatusCode(300).forStatusCodeMatching(it -> it == 500) + ) + ) { + waitUntilReadyAndTimeout(container); + } } /** @@ -120,11 +152,15 @@ public void testWaitUntilReadyWithTimeoutAndWithManyStatusCodesAndLambda() { * Test case for: https://github.com/testcontainers/testcontainers-java/issues/880 */ @Test - public void testWaitUntilReadyWithTimeoutAndWithLambdaShouldNotMatchOk() { - waitUntilReadyAndTimeout(startContainerWithCommand(createShellCommand("200 OK", GOOD_RESPONSE_BODY), - createHttpWaitStrategy(ready) - .forStatusCodeMatching(it -> it >= 300) - )); + void testWaitUntilReadyWithTimeoutAndWithLambdaShouldNotMatchOk() { + try ( + GenericContainer container = startContainerWithCommand( + createShellCommand("200 OK", GOOD_RESPONSE_BODY), + createHttpWaitStrategy(ready).forStatusCodeMatching(it -> it >= 300) + ) + ) { + waitUntilReadyAndTimeout(container); + } } /** @@ -132,7 +168,7 @@ public void testWaitUntilReadyWithTimeoutAndWithLambdaShouldNotMatchOk() { * response from the container within the timeout period. */ @Test - public void testWaitUntilReadyWithTimeout() { + void testWaitUntilReadyWithTimeout() { waitUntilReadyAndTimeout(createShellCommand("400 Bad Request", GOOD_RESPONSE_BODY)); } @@ -141,31 +177,64 @@ public void testWaitUntilReadyWithTimeout() { * from the container within the timeout period. */ @Test - public void testWaitUntilReadyWithTimeoutAndBadResponseBody() { + void testWaitUntilReadyWithTimeoutAndBadResponseBody() { waitUntilReadyAndTimeout(createShellCommand("200 OK", "Bad Response")); } - /** * Expects the WaitStrategy probing the right port. */ @Test - public void testWaitUntilReadyWithSpecificPort() { - waitUntilReadyAndSucceed(startContainerWithCommand( - createShellCommand("200 OK", GOOD_RESPONSE_BODY, 9090), - createHttpWaitStrategy(ready) - .forPort(9090), - 7070, 8080, 9090 - )); + void testWaitUntilReadyWithSpecificPort() { + try ( + GenericContainer container = startContainerWithCommand( + createShellCommand("200 OK", GOOD_RESPONSE_BODY, 9090), + createHttpWaitStrategy(ready).forPort(9090), + 7070, + 8080, + 9090 + ) + ) { + waitUntilReadyAndSucceed(container); + } } @Test - public void testWaitUntilReadyWithTimoutCausedByReadTimeout() { - waitUntilReadyAndTimeout( - startContainerWithCommand(createShellCommand("0 Connection Refused", GOOD_RESPONSE_BODY, 9090), + 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"); + } } /** @@ -174,8 +243,7 @@ public void testWaitUntilReadyWithTimoutCausedByReadTimeout() { */ @NotNull protected HttpWaitStrategy buildWaitStrategy(final AtomicBoolean ready) { - return createHttpWaitStrategy(ready) - .forResponsePredicate(s -> s.equals(GOOD_RESPONSE_BODY)); + return createHttpWaitStrategy(ready).forResponsePredicate(s -> s.equals(GOOD_RESPONSE_BODY)); } /** @@ -201,9 +269,41 @@ private String createShellCommand(String header, String responseBody) { 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"; + 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 index a6ef9f11ba4..537254a60a1 100644 --- a/core/src/test/java/org/testcontainers/junit/wait/strategy/LogMessageWaitStrategyTest.java +++ b/core/src/test/java/org/testcontainers/junit/wait/strategy/LogMessageWaitStrategyTest.java @@ -1,9 +1,9 @@ package org.testcontainers.junit.wait.strategy; import org.jetbrains.annotations.NotNull; -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.containers.wait.strategy.LogMessageWaitStrategy; import java.util.concurrent.atomic.AtomicBoolean; @@ -11,17 +11,17 @@ /** * Tests for {@link LogMessageWaitStrategy}. */ -@RunWith(Parameterized.class) -public class LogMessageWaitStrategyTest extends AbstractWaitStrategyTest { +@ParameterizedClass(name = "{0}") +@MethodSource("parameters") +class LogMessageWaitStrategyTest extends AbstractWaitStrategyTest { private final String pattern; - @Parameterized.Parameters(name = "{0}") 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) + ".*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) }; } @@ -32,30 +32,35 @@ public LogMessageWaitStrategyTest(String pattern) { private static final String READY_MESSAGE = "I'm ready!"; @Test - public void testWaitUntilReady_Success() { - waitUntilReadyAndSucceed("echo -e \"" + READY_MESSAGE + "\";" + - "echo -e \"foobar\";" + - "echo -e \"" + READY_MESSAGE + "\";" + - "sleep 300"); + void testWaitUntilReady_Success() { + waitUntilReadyAndSucceed( + "echo -e \"" + + READY_MESSAGE + + "\";" + + "echo -e \"foobar\";" + + "echo -e \"" + + READY_MESSAGE + + "\";" + + "sleep 300" + ); } @Test - public void testWaitUntilReady_Timeout() { - waitUntilReadyAndTimeout("echo -e \"" + READY_MESSAGE + "\";" + - "echo -e \"foobar\";" + - "sleep 300"); + 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); + } + .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 index c99770c4fbf..171bd9a4381 100644 --- a/core/src/test/java/org/testcontainers/utility/AuthenticatedImagePullTest.java +++ b/core/src/test/java/org/testcontainers/utility/AuthenticatedImagePullTest.java @@ -1,35 +1,28 @@ package org.testcontainers.utility; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.testcontainers.TestImages.DOCKER_REGISTRY_IMAGE; -import static org.testcontainers.TestImages.TINY_IMAGE; - -import com.github.dockerjava.api.DockerClient; -import com.github.dockerjava.api.async.ResultCallback; -import com.github.dockerjava.api.command.PullImageResultCallback; import com.github.dockerjava.api.model.AuthConfig; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.concurrent.TimeUnit; import org.intellij.lang.annotations.Language; -import org.junit.AfterClass; -import org.junit.Before; -import org.junit.BeforeClass; -import org.junit.ClassRule; -import org.junit.Test; +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.DockerClientFactory; +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.containers.wait.strategy.HttpWaitStrategy; 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 @@ -43,33 +36,30 @@ public class AuthenticatedImagePullTest { /** * Containerised docker image registry, with simple hardcoded credentials */ - @ClassRule - public static GenericContainer authenticatedRegistry = new GenericContainer<>(new ImageFromDockerfile() - .withDockerfileFromBuilder(builder -> { - builder.from(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"); - })) - .withExposedPorts(5000) - .waitingFor(new HttpWaitStrategy()); + 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 static DockerClient client; - private static String testImageName; - private static RegistryAuthLocator mockAuthLocator; + private final DockerImageName testImageName = authenticatedRegistry.createImage(); + + @BeforeAll + public static void beforeClass() throws Exception { + authenticatedRegistry.start(); - @BeforeClass - public static void setUp() throws InterruptedException { originalAuthLocatorSingleton = RegistryAuthLocator.instance(); - client = DockerClientFactory.instance().client(); - String testRegistryAddress = authenticatedRegistry.getHost() + ":" + authenticatedRegistry.getFirstMappedPort(); - testImageName = testRegistryAddress + "/alpine"; + String testRegistryAddress = authenticatedRegistry.getEndpoint(); - final DockerImageName expectedName = DockerImageName.parse(testImageName); final AuthConfig authConfig = new AuthConfig() .withUsername("testuser") .withPassword("notasecret") @@ -78,76 +68,81 @@ public static void setUp() throws InterruptedException { // 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(eq(expectedName), any())) + when( + mockAuthLocator.lookupAuthConfig( + argThat(argument -> testRegistryAddress.equals(argument.getRegistry())), + any() + ) + ) .thenReturn(authConfig); - - // a push will use the auth locator for authentication, although that isn't the goal of this test - putImageInRegistry(); } - @Before - public void removeImageFromLocalDocker() { - // remove the image tag from local docker so that it must be pulled before use - client.removeImageCmd(testImageName).withForce(true).exec(); - } - - @AfterClass + @AfterAll public static void tearDown() { RegistryAuthLocator.setInstance(originalAuthLocatorSingleton); } @Test - public void testThatAuthLocatorIsUsedForContainerCreation() { + void testThatAuthLocatorIsUsedForContainerCreation() { // actually start a container, which will require an authenticated pull - try (final GenericContainer container = new GenericContainer<>(DockerImageName.parse(testImageName)) - .withCommand("/bin/sh", "-c", "sleep 10")) { + try ( + final GenericContainer container = new GenericContainer<>(testImageName) + .withCommand("/bin/sh", "-c", "sleep 10") + ) { container.start(); - assertTrue("container started following an authenticated pull", container.isRunning()); + assertThat(container.isRunning()).as("container started following an authenticated pull").isTrue(); } } @Test - public void testThatAuthLocatorIsUsedForDockerfileBuild() throws IOException { + void testThatAuthLocatorIsUsedForDockerfileBuild() throws IOException { // Prepare a simple temporary Dockerfile which requires our custom private image Path tempFile = getLocalTempFile(".Dockerfile"); - String dockerFileContent = "FROM " + testImageName; + 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")) { + try ( + final GenericContainer container = new GenericContainer<>( + new ImageFromDockerfile().withDockerfile(tempFile) + ) + .withCommand("/bin/sh", "-c", "sleep 10") + ) { container.start(); - assertTrue("container started following an authenticated pull", container.isRunning()); + assertThat(container.isRunning()).as("container started following an authenticated pull").isTrue(); } } @Test - public void testThatAuthLocatorIsUsedForDockerComposePull() throws IOException { + 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 = + @Language("yaml") + String composeFileContent = "version: '2.0'\n" + - "services:\n" + - " privateservice:\n" + - " command: /bin/sh -c 'sleep 60'\n" + - " image: " + testImageName; + "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<>(tempFile.toFile())) { + try ( + final DockerComposeContainer compose = new DockerComposeContainer<>( + DockerImageName.parse("docker/compose:1.29.2"), + tempFile.toFile() + ) + ) { compose.start(); - assertTrue("container started following an authenticated pull", - compose - .getContainerByServiceName("privateservice_1") - .map(ContainerState::isRunning) - .orElse(false) - ); + assertThat( + compose.getContainerByServiceName("privateservice_1").map(ContainerState::isRunning).orElse(false) + ) + .as("container started following an authenticated pull") + .isTrue(); } } @@ -162,24 +157,4 @@ private Path getLocalTempFile(String s) throws IOException { return tempFile; } - - private static void putImageInRegistry() throws InterruptedException { - // It doesn't matter which image we use for this test, but use one that's likely to have been pulled already - final String dummySourceImage = TINY_IMAGE.asCanonicalNameString(); - - client.pullImageCmd(dummySourceImage) - .exec(new PullImageResultCallback()) - .awaitCompletion(1, TimeUnit.MINUTES); - - final String id = client.inspectImageCmd(dummySourceImage) - .exec() - .getId(); - - // push the image to the registry - client.tagImageCmd(id, testImageName, "").exec(); - - client.pushImageCmd(testImageName) - .exec(new ResultCallback.Adapter<>()) - .awaitCompletion(1, TimeUnit.MINUTES); - } } diff --git a/core/src/test/java/org/testcontainers/utility/ClasspathScannerTest.java b/core/src/test/java/org/testcontainers/utility/ClasspathScannerTest.java index bdd71b0db9f..63812c998e3 100644 --- a/core/src/test/java/org/testcontainers/utility/ClasspathScannerTest.java +++ b/core/src/test/java/org/testcontainers/utility/ClasspathScannerTest.java @@ -1,29 +1,33 @@ package org.testcontainers.utility; -import org.junit.BeforeClass; -import org.junit.Test; +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 java.util.Arrays.asList; -import static java.util.stream.Collectors.toList; +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; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -public class ClasspathScannerTest { +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; - @BeforeClass + @BeforeAll public static void setUp() throws Exception { FILE_A = new URL("file:///a/someName"); FILE_B = new URL("file:///b/someName"); @@ -33,82 +37,59 @@ public static void setUp() throws Exception { } @Test - public void realClassLoaderLookupOccurs() { + void realClassLoaderLookupOccurs() { // look for a resource that we know exists only once - final List foundURLs = ClasspathScanner.scanFor("expectedClasspathFile.txt").collect(toList()); + final List foundURLs = ClasspathScanner.scanFor("expectedClasspathFile.txt").collect(Collectors.toList()); - assertEquals("Exactly one resource was found", 1, foundURLs.size()); + assertThat(foundURLs).as("Exactly one resource was found").hasSize(1); } @Test - public void multipleResultsOnOneClassLoaderAreFound() throws IOException { + void multipleResultsOnOneClassLoaderAreFound() throws IOException { final ClassLoader firstMockClassLoader = mock(ClassLoader.class); - when(firstMockClassLoader.getResources(eq("someName"))).thenReturn( - Collections.enumeration( - asList( - FILE_A, - FILE_B - ) - ) - ); - - final List foundURLs = ClasspathScanner.scanFor("someName", firstMockClassLoader).collect(toList()); - assertEquals( - "The expected URLs are found", - asList(FILE_A, FILE_B), - foundURLs - ); + 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 - public void orderIsAlphabeticalForDeterminism() throws IOException { + void orderIsAlphabeticalForDeterminism() throws IOException { final ClassLoader firstMockClassLoader = mock(ClassLoader.class); - when(firstMockClassLoader.getResources(eq("someName"))).thenReturn( - Collections.enumeration( - asList( - FILE_B, - JAR_A, - JAR_B, - FILE_A - ) - ) - ); - - final List foundURLs = ClasspathScanner.scanFor("someName", firstMockClassLoader).collect(toList()); - assertEquals( - "The expected URLs are found in the expected order", - asList(FILE_A, FILE_B, JAR_A, JAR_B), - foundURLs - ); + 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 - public void multipleClassLoadersAreQueried() throws IOException { + void multipleClassLoadersAreQueried() throws IOException { final ClassLoader firstMockClassLoader = mock(ClassLoader.class); - when(firstMockClassLoader.getResources(eq("someName"))).thenReturn( - Collections.enumeration( - asList( - FILE_A, - FILE_B - ) - ) - ); + 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( - asList( - FILE_B, // duplicate - FILE_C + when(secondMockClassLoader.getResources(eq("someName"))) + .thenReturn( + Collections.enumeration( + Arrays.asList( + FILE_B, // duplicate + FILE_C + ) ) - ) - ); + ); - final List foundURLs = ClasspathScanner.scanFor("someName", firstMockClassLoader, secondMockClassLoader).collect(toList()); + final List foundURLs = ClasspathScanner + .scanFor("someName", firstMockClassLoader, secondMockClassLoader) + .collect(Collectors.toList()); - assertEquals( - "The expected URLs are found", - asList(FILE_A, FILE_B, FILE_C), - foundURLs - ); + 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 index d5a2c63a5b8..dadf3983926 100644 --- a/core/src/test/java/org/testcontainers/utility/ComparableVersionTest.java +++ b/core/src/test/java/org/testcontainers/utility/ComparableVersionTest.java @@ -1,40 +1,30 @@ package org.testcontainers.utility; -import java.util.Arrays; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.junit.runners.Parameterized.Parameters; - -import static org.junit.Assert.assertArrayEquals; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; -@RunWith(Parameterized.class) -public class ComparableVersionTest { +import java.util.Arrays; - private final int[] expected; - private final String given; +import static org.assertj.core.api.Assertions.assertThat; - public ComparableVersionTest(final String given, final int[] expected) { - this.given = given; - this.expected = expected; - } +class ComparableVersionTest { - @Test - public void shouldParseVersions() { - assertArrayEquals(expected, ComparableVersion.parseVersion(given)); + @ParameterizedTest(name = "Parsed version: {0}={1}") + @MethodSource("data") + void shouldParseVersions(String given, int[] expected) { + assertThat(ComparableVersion.parseVersion(given)).containsExactly(expected); } - @Parameters(name = "Parsed version: {0}={1}") 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}}, - }); + 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 index a6f7813c3d3..143455d5b16 100644 --- a/core/src/test/java/org/testcontainers/utility/DefaultImageNameSubstitutorTest.java +++ b/core/src/test/java/org/testcontainers/utility/DefaultImageNameSubstitutorTest.java @@ -1,30 +1,29 @@ package org.testcontainers.utility; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; +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.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; -public class DefaultImageNameSubstitutorTest { +@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; - @Rule - public MockTestcontainersConfigurationRule config = new MockTestcontainersConfigurationRule(); + private ConfigurationFileImageNameSubstitutor underTest; - @Before + @BeforeEach public void setUp() { underTest = new ConfigurationFileImageNameSubstitutor(TestcontainersConfiguration.getInstance()); } @Test - public void testConfigurationLookup() { + void testConfigurationLookup() { Mockito .doReturn(SUBSTITUTE_IMAGE) .when(TestcontainersConfiguration.getInstance()) @@ -32,7 +31,7 @@ public void testConfigurationLookup() { final DockerImageName substitute = underTest.apply(ORIGINAL_IMAGE); - assertEquals("match is found", SUBSTITUTE_IMAGE, substitute); - assertTrue("compatibility is automatically set", substitute.isCompatibleWith(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 index 3789a654c20..bfd3641adeb 100644 --- a/core/src/test/java/org/testcontainers/utility/DirectoryTarResourceTest.java +++ b/core/src/test/java/org/testcontainers/utility/DirectoryTarResourceTest.java @@ -1,112 +1,97 @@ package org.testcontainers.utility; -import org.hamcrest.Description; -import org.hamcrest.Matcher; -import org.hamcrest.core.IsCollectionContaining; -import org.junit.Test; +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.Arrays; +import java.util.function.Predicate; -import static org.hamcrest.CoreMatchers.allOf; -import static org.hamcrest.CoreMatchers.containsString; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThat; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; -public class DirectoryTarResourceTest { +class DirectoryTarResourceTest { @Test - public void simpleRecursiveFileTest() { + 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.3") - .copy("/tmp/foo", "/foo") - .cmd("cat /foo/test/resources/test-recursive-file.txt") - .build() - ).withFileFromFile("/tmp/foo", directory)) - .withStartupCheckStrategy(new OneShotStartupCheckStrategy()); + 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(); - assertTrue("The container has a file that was copied in via a recursive copy", results.contains("Used for DirectoryTarResourceTest")); + assertThat(results) + .as("The container has a file that was copied in via a recursive copy") + .contains("Used for DirectoryTarResourceTest"); } @Test - public void simpleRecursiveFileWithPermissionTest() { - GenericContainer container = new GenericContainer( + void simpleRecursiveFileWithPermissionTest() { + try ( + GenericContainer container = new GenericContainer( new ImageFromDockerfile() - .withDockerfileFromBuilder(builder -> - builder.from("alpine:3.3") - .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(); - - assertThat("Listing shows that file is copied with mode requested.", - Arrays.asList(listing.split("\\n")), - exactlyNItems(1, allOf(containsString("-rwxr-xr--"), containsString("foo")))); + .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 - public void simpleRecursiveClasspathResourceTest() { + 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 - GenericContainer container = new GenericContainer( + try ( + GenericContainer container = new GenericContainer( new ImageFromDockerfile() - .withDockerfileFromBuilder(builder -> - builder.from("alpine:3.3") - .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 - assertTrue("The container has a file that was copied in via a recursive copy from a JAR resource", results.contains("content.txt")); - } - - public static Matcher> exactlyNItems(final int n, Matcher elementMatcher) { - return new IsCollectionContaining(elementMatcher) { - @Override - protected boolean matchesSafely(Iterable collection, Description mismatchDescription) { - int count = 0; - boolean isPastFirst = false; - - for (Object item : collection) { - - if (elementMatcher.matches(item)) { - count++; - } - if (isPastFirst) { - mismatchDescription.appendText(", "); - } - elementMatcher.describeMismatch(item, mismatchDescription); - isPastFirst = true; - } - - if (count != n) { - mismatchDescription.appendText(". Expected exactly " + n + " but got " + count); - } - return count == n; - } - }; + .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 index 22d67de5785..ce0759fec47 100644 --- a/core/src/test/java/org/testcontainers/utility/DockerImageNameCompatibilityTest.java +++ b/core/src/test/java/org/testcontainers/utility/DockerImageNameCompatibilityTest.java @@ -1,27 +1,21 @@ package org.testcontainers.utility; -import static org.hamcrest.core.StringContains.containsString; -import static org.rnorth.visibleassertions.VisibleAssertions.assertFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import org.junit.jupiter.api.Test; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; - -public class DockerImageNameCompatibilityTest { - - @Rule - public ExpectedException thrown = ExpectedException.none(); +class DockerImageNameCompatibilityTest { @Test - public void testPlainImage() { + void testPlainImage() { DockerImageName subject = DockerImageName.parse("foo"); - assertFalse("image name foo != bar", subject.isCompatibleWith(DockerImageName.parse("bar"))); + assertThat(subject.isCompatibleWith(DockerImageName.parse("bar"))).as("image name foo != bar").isFalse(); } + @Test - public void testNoTagTreatedAsWildcard() { + void testNoTagTreatedAsWildcard() { final DockerImageName subject = DockerImageName.parse("foo:4.5.6"); /* foo:1.2.3 != foo:4.5.6 @@ -29,64 +23,114 @@ public void testNoTagTreatedAsWildcard() { The test is effectively making sure that 'no tag' is treated as a wildcard */ - assertFalse("foo:4.5.6 != foo:1.2.3", subject.isCompatibleWith(DockerImageName.parse("foo:1.2.3"))); - assertTrue("foo:4.5.6 ~= foo", subject.isCompatibleWith(DockerImageName.parse("foo"))); + 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 - public void testImageWithAutomaticCompatibilityForFullPath() { + void testImageWithAutomaticCompatibilityForFullPath() { DockerImageName subject = DockerImageName.parse("repo/foo:1.2.3"); - assertTrue("repo/foo:1.2.3 ~= repo/foo", subject.isCompatibleWith(DockerImageName.parse("repo/foo"))); + assertThat(subject.isCompatibleWith(DockerImageName.parse("repo/foo"))) + .as("repo/foo:1.2.3 ~= repo/foo") + .isTrue(); } @Test - public void testImageWithClaimedCompatibility() { + void testImageWithClaimedCompatibility() { DockerImageName subject = DockerImageName.parse("foo").asCompatibleSubstituteFor("bar"); - assertTrue("foo(bar) ~= bar", subject.isCompatibleWith(DockerImageName.parse("bar"))); - assertFalse("foo(bar) != fizz", subject.isCompatibleWith(DockerImageName.parse("fizz"))); + assertThat(subject.isCompatibleWith(DockerImageName.parse("bar"))).as("foo(bar) ~= bar").isTrue(); + assertThat(subject.isCompatibleWith(DockerImageName.parse("fizz"))).as("foo(bar) != fizz").isFalse(); } @Test - public void testImageWithClaimedCompatibilityAndVersion() { + void testImageWithClaimedCompatibilityAndVersion() { DockerImageName subject = DockerImageName.parse("foo:1.2.3").asCompatibleSubstituteFor("bar"); - assertTrue("foo:1.2.3(bar) ~= bar", subject.isCompatibleWith(DockerImageName.parse("bar"))); + assertThat(subject.isCompatibleWith(DockerImageName.parse("bar"))).as("foo:1.2.3(bar) ~= bar").isTrue(); } @Test - public void testImageWithClaimedCompatibilityForFullPath() { + void testImageWithClaimedCompatibilityForFullPath() { DockerImageName subject = DockerImageName.parse("foo").asCompatibleSubstituteFor("registry/repo/bar"); - assertTrue("foo(registry/repo/bar) ~= registry/repo/bar", subject.isCompatibleWith(DockerImageName.parse("registry/repo/bar"))); - assertFalse("foo(registry/repo/bar) != repo/bar", subject.isCompatibleWith(DockerImageName.parse("repo/bar"))); - assertFalse("foo(registry/repo/bar) != bar", subject.isCompatibleWith(DockerImageName.parse("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 - public void testImageWithClaimedCompatibilityForVersion() { + void testImageWithClaimedCompatibilityForVersion() { DockerImageName subject = DockerImageName.parse("foo").asCompatibleSubstituteFor("bar:1.2.3"); - assertTrue("foo(bar:1.2.3) ~= bar", subject.isCompatibleWith(DockerImageName.parse("bar"))); - assertTrue("foo(bar:1.2.3) ~= bar:1.2.3", subject.isCompatibleWith(DockerImageName.parse("bar:1.2.3"))); - assertFalse("foo(bar:1.2.3) != bar:0.0.1", subject.isCompatibleWith(DockerImageName.parse("bar:0.0.1"))); - assertFalse("foo(bar:1.2.3) != bar:2.0.0", subject.isCompatibleWith(DockerImageName.parse("bar:2.0.0"))); - assertFalse("foo(bar:1.2.3) != bar:1.2.4", subject.isCompatibleWith(DockerImageName.parse("bar:1.2.4"))); + 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 - public void testAssertMethodAcceptsCompatible() { + 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 - public void testAssertMethodRejectsIncompatible() { - thrown.expect(IllegalStateException.class); - thrown.expectMessage(containsString("Failed to verify that image 'foo' is a compatible substitute for 'bar'")); + void testAssertMethodAcceptsCompatibleLibraryPrefix() { + DockerImageName subject = DockerImageName.parse("library/foo"); + subject.assertCompatibleWith(DockerImageName.parse("foo")); + } + @Test + void testAssertMethodRejectsIncompatible() { DockerImageName subject = DockerImageName.parse("foo"); - subject.assertCompatibleWith(DockerImageName.parse("bar")); + 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 e06407fbf56..5b42322d678 100644 --- a/core/src/test/java/org/testcontainers/utility/DockerImageNameTest.java +++ b/core/src/test/java/org/testcontainers/utility/DockerImageNameTest.java @@ -1,22 +1,23 @@ package org.testcontainers.utility; -import org.junit.Test; -import org.junit.experimental.runners.Enclosed; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.rnorth.visibleassertions.VisibleAssertions; +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; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; +import java.util.stream.Stream; -@RunWith(Enclosed.class) -public class DockerImageNameTest { +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; - @RunWith(Parameterized.class) - public static class ValidNames { +class DockerImageNameTest { + + @Nested + class ValidNames { - @Parameterized.Parameters(name = "{0}") public static String[] getNames() { - return new String[]{ + return new String[] { "myname:latest", "repo/my-name:1.0", "registry.foo.com:1234/my-name:1.0", @@ -24,85 +25,87 @@ public static String[] getNames() { "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"}; + "1.2.3.4:1234/repo-here/my-name:1.0", + }; } - @Parameterized.Parameter - public String imageName; - - @Test - public void testValidNameAccepted() { + @ParameterizedTest + @MethodSource("getNames") + void testValidNameAccepted(String imageName) { DockerImageName.parse(imageName).assertValid(); } } - @RunWith(Parameterized.class) - public static class InvalidNames { + @Nested + class InvalidNames { - @Parameterized.Parameters(name = "{0}") public static String[] getNames() { - return new String[]{":latest", + return new String[] { + ":latest", "/myname:latest", "/myname@sha256:latest", "/myname@sha256:gggggggggggggggggggggggggggggggg", - "repo:notaport/myname:latest"}; + "repo:notaport/myname:latest", + }; } - @Parameterized.Parameter - public String imageName; - - @Test(expected = IllegalArgumentException.class) - public void testInvalidNameRejected() { - DockerImageName.parse(imageName).assertValid(); + @ParameterizedTest + @MethodSource("getNames") + void testInvalidNameRejected(String imageName) { + assertThatThrownBy(() -> DockerImageName.parse(imageName).assertValid()) + .isInstanceOf(IllegalArgumentException.class); } } - @RunWith(Parameterized.class) - public static class Parsing { - - @Parameterized.Parameters(name = "{0}{1}{2}{3}{4}") - public static String[][] getNames() { - return new String[][]{ - {"", "", "myname", ":", null}, - {"", "", "myname", ":", "latest"}, - {"", "", "repo/myname", ":", null}, - {"", "", "repo/myname", ":", "latest"}, - {"registry.foo.com:1234", "/", "my-name", ":", null}, - {"registry.foo.com:1234", "/", "my-name", ":", "1.0"}, - {"registry.foo.com", "/", "my-name", ":", "1.0"}, - {"registry.foo.com:1234", "/", "repo_here/my-name", ":", null}, - {"registry.foo.com:1234", "/", "repo_here/my-name", ":", "1.0"}, - {"1.2.3.4:1234", "/", "repo_here/my-name", ":", null}, - {"1.2.3.4:1234", "/", "repo_here/my-name", ":", "1.0"}, - {"1.2.3.4:1234", "/", "my-name", ":", null}, - {"1.2.3.4:1234", "/", "my-name", ":", "1.0"}, - {"", "", "myname", "@", "sha256:1234abcd1234abcd1234abcd1234abcd"}, - {"", "", "repo/myname", "@", "sha256:1234abcd1234abcd1234abcd1234abcd"}, - {"registry.foo.com:1234", "/", "repo-here/my-name", "@", "sha256:1234abcd1234abcd1234abcd1234abcd"}, - {"registry.foo.com:1234", "/", "my-name", "@", "sha256:1234abcd1234abcd1234abcd1234abcd"}, - {"1.2.3.4", "/", "my-name", "@", "sha256:1234abcd1234abcd1234abcd1234abcd"}, - {"1.2.3.4:1234", "/", "my-name", "@", "sha256:1234abcd1234abcd1234abcd1234abcd"}, - {"1.2.3.4", "/", "my-name", "@", "sha256:1234abcd1234abcd1234abcd1234abcd"}, - {"1.2.3.4:1234", "/", "my-name", "@", "sha256:1234abcd1234abcd1234abcd1234abcd"} - }; + @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") + ); } - @Parameterized.Parameter(0) - public String registry; - @Parameterized.Parameter(1) - public String registrySeparator; - @Parameterized.Parameter(2) - public String repo; - @Parameterized.Parameter(3) - public String versionSeparator; - @Parameterized.Parameter(4) - public String version; - - @Test - public void testParsing() { + @ParameterizedTest + @MethodSource("getNames") + void testParsing( + String registry, + String registrySeparator, + String repo, + String versionSeparator, + String version + ) { final String unversionedPart = registry + registrySeparator + repo; String combined; @@ -115,27 +118,104 @@ public void testParsing() { canonicalName = unversionedPart + ":latest"; } - VisibleAssertions.context("For " + combined); - VisibleAssertions.context("Using single-arg constructor:", 2); - final DockerImageName imageName = DockerImageName.parse(combined); - assertEquals(combined + " has registry address: " + registry, registry, imageName.getRegistry()); - assertEquals(combined + " has unversioned part: " + unversionedPart, unversionedPart, imageName.getUnversionedPart()); + 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) { - assertEquals(combined + " has version part: " + version, version, imageName.getVersionPart()); + assertThat(imageName.getVersionPart()) + .as(combined + " has version part: " + version) + .isEqualTo(version); } else { - assertEquals(combined + " has automatic 'latest' version specified", "latest", imageName.getVersionPart()); + assertThat(imageName.getVersionPart()) + .as(combined + " has automatic 'latest' version specified") + .isEqualTo("latest"); } - assertEquals(combined + " has canonical name: " + canonicalName, canonicalName, imageName.asCanonicalNameString()); + assertThat(imageName.asCanonicalNameString()) + .as(combined + " has canonical name: " + canonicalName) + .isEqualTo(canonicalName); if (version != null) { - VisibleAssertions.context("Using two-arg constructor:", 2); - final DockerImageName imageNameFromSecondaryConstructor = new DockerImageName(unversionedPart, version); - assertEquals(combined + " has registry address: " + registry, registry, imageNameFromSecondaryConstructor.getRegistry()); - assertEquals(combined + " has unversioned part: " + unversionedPart, unversionedPart, imageNameFromSecondaryConstructor.getUnversionedPart()); - assertEquals(combined + " has version part: " + version, version, imageNameFromSecondaryConstructor.getVersionPart()); + 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); } } } + + @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"); + } + } } 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 index f74a6c942b3..50681ddc729 100644 --- a/core/src/test/java/org/testcontainers/utility/FakeImageSubstitutor.java +++ b/core/src/test/java/org/testcontainers/utility/FakeImageSubstitutor.java @@ -1,6 +1,7 @@ package org.testcontainers.utility; public class FakeImageSubstitutor extends ImageNameSubstitutor { + @Override public DockerImageName apply(final DockerImageName original) { return DockerImageName.parse("transformed-" + original.asCanonicalNameString()); diff --git a/core/src/test/java/org/testcontainers/utility/FilterRegistryTest.java b/core/src/test/java/org/testcontainers/utility/FilterRegistryTest.java index 49bc870e8d0..af71e49664e 100644 --- a/core/src/test/java/org/testcontainers/utility/FilterRegistryTest.java +++ b/core/src/test/java/org/testcontainers/utility/FilterRegistryTest.java @@ -1,6 +1,6 @@ package org.testcontainers.utility; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.testcontainers.utility.ResourceReaper.FilterRegistry; import java.io.ByteArrayInputStream; @@ -9,50 +9,53 @@ 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 java.util.Arrays.asList; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; -public class FilterRegistryTest { +class FilterRegistryTest { - private static final List> FILTERS = asList( - new SimpleEntry<>("key1!", "value2?"), new SimpleEntry<>("key2#", "value2%") + 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 - public void registerReturnsTrueIfAcknowledgementIsReadFromInputStream() throws IOException { + void registerReturnsTrueIfAcknowledgementIsReadFromInputStream() throws IOException { FilterRegistry registry = new FilterRegistry(inputStream(ACKNOWLEDGEMENT), anyOutputStream()); boolean successful = registry.register(FILTERS); - assertTrue(successful); + assertThat(successful).isTrue(); } @Test - public void registerReturnsFalseIfNoAcknowledgementIsReadFromInputStream() throws IOException { + void registerReturnsFalseIfNoAcknowledgementIsReadFromInputStream() throws IOException { FilterRegistry registry = new FilterRegistry(inputStream(NO_ACKNOWLEDGEMENT), anyOutputStream()); boolean successful = registry.register(FILTERS); - assertFalse(successful); + assertThat(successful).isFalse(); } @Test - public void registerWritesUrlEncodedFiltersAndNewlineToOutputStream() throws IOException { + void registerWritesUrlEncodedFiltersAndNewlineToOutputStream() throws IOException { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); FilterRegistry registry = new FilterRegistry(anyInputStream(), outputStream); registry.register(FILTERS); - assertEquals(URL_ENCODED_FILTERS + NEW_LINE, new String(outputStream.toByteArray())); + assertThat(new String(outputStream.toByteArray())).isEqualTo(URL_ENCODED_FILTERS + NEW_LINE); } private static InputStream inputStream(byte[] bytes) { @@ -66,5 +69,4 @@ private static InputStream anyInputStream() { 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 index 97ee0213f21..e6a0c294c77 100644 --- a/core/src/test/java/org/testcontainers/utility/ImageNameSubstitutorTest.java +++ b/core/src/test/java/org/testcontainers/utility/ImageNameSubstitutorTest.java @@ -1,22 +1,36 @@ package org.testcontainers.utility; -import org.junit.After; -import org.junit.Before; -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.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; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -public class ImageNameSubstitutorTest { +@ExtendWith(MockTestcontainersConfigurationExtension.class) +class ImageNameSubstitutorTest { + + @TempDir + public Path tempFolder; - @Rule - public MockTestcontainersConfigurationRule config = new MockTestcontainersConfigurationRule(); private ImageNameSubstitutor originalInstance; + private ImageNameSubstitutor originalDefaultImplementation; - @Before + @BeforeEach public void setUp() throws Exception { originalInstance = ImageNameSubstitutor.instance; originalDefaultImplementation = ImageNameSubstitutor.defaultImplementation; @@ -27,20 +41,17 @@ public void setUp() throws Exception { .doReturn(DockerImageName.parse("substituted-image")) .when(ImageNameSubstitutor.defaultImplementation) .apply(eq(DockerImageName.parse("original"))); - Mockito - .doReturn("default implementation") - .when(ImageNameSubstitutor.defaultImplementation) - .getDescription(); + Mockito.doReturn("default implementation").when(ImageNameSubstitutor.defaultImplementation).getDescription(); } - @After + @AfterEach public void tearDown() throws Exception { ImageNameSubstitutor.instance = originalInstance; ImageNameSubstitutor.defaultImplementation = originalDefaultImplementation; } @Test - public void simpleConfigurationTest() { + void simpleConfigurationTest() { Mockito .doReturn(FakeImageSubstitutor.class.getCanonicalName()) .when(TestcontainersConfiguration.getInstance()) @@ -49,27 +60,78 @@ public void simpleConfigurationTest() { final ImageNameSubstitutor imageNameSubstitutor = ImageNameSubstitutor.instance(); DockerImageName result = imageNameSubstitutor.apply(DockerImageName.parse("original")); - assertEquals( - "the image has been substituted by default then configured implementations", - "transformed-substituted-image:latest", - result.asCanonicalNameString() - ); + 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 - public void testWorksWithoutConfiguredImplementation() { + void testImageNameSubstitutorToString() { Mockito - .doReturn(null) + .doReturn(FakeImageSubstitutor.class.getCanonicalName()) .when(TestcontainersConfiguration.getInstance()) .getImageSubstitutorClassName(); - final ImageNameSubstitutor imageNameSubstitutor = ImageNameSubstitutor.instance(); + try (GenericContainer container = new GenericContainer<>(DockerImageName.parse("original"))) { + assertThatThrownBy(container::start) + .hasMessageContaining( + "imageNameSubstitutor=Chained substitutor of 'default implementation' and then 'test implementation'" + ); + } + } - DockerImageName result = imageNameSubstitutor.apply(DockerImageName.parse("original")); - assertEquals( - "the image has been substituted by default then configured implementations", - "substituted-image:latest", - result.asCanonicalNameString() + @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 index 63da017c88e..c0ca63c06bc 100644 --- a/core/src/test/java/org/testcontainers/utility/LicenseAcceptanceTest.java +++ b/core/src/test/java/org/testcontainers/utility/LicenseAcceptanceTest.java @@ -1,17 +1,20 @@ package org.testcontainers.utility; -import org.junit.Test; +import org.junit.jupiter.api.Test; -public class LicenseAcceptanceTest { +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LicenseAcceptanceTest { @Test - public void testForExistingNames() { + void testForExistingNames() { LicenseAcceptance.assertLicenseAccepted("a"); LicenseAcceptance.assertLicenseAccepted("b"); } - @Test(expected = IllegalStateException.class) - public void testForMissingNames() { - LicenseAcceptance.assertLicenseAccepted("c"); + @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/MockTestcontainersConfigurationRule.java b/core/src/test/java/org/testcontainers/utility/MockTestcontainersConfigurationRule.java deleted file mode 100644 index ba467f13897..00000000000 --- a/core/src/test/java/org/testcontainers/utility/MockTestcontainersConfigurationRule.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.testcontainers.utility; - -import org.jetbrains.annotations.NotNull; -import org.junit.rules.TestRule; -import org.junit.runner.Description; -import org.junit.runners.model.Statement; -import org.mockito.Mockito; - -import java.util.concurrent.atomic.AtomicReference; - -/** - * This {@link TestRule} applies a spy on {@link TestcontainersConfiguration} - * for testing features that depend on the global configuration. - */ -public class MockTestcontainersConfigurationRule implements TestRule { - - static AtomicReference REF = TestcontainersConfiguration.getInstanceField(); - - @NotNull - @Override - public Statement apply(@NotNull Statement base, @NotNull Description description) { - return new Statement() { - @Override - public void evaluate() throws Throwable { - TestcontainersConfiguration previous = REF.get(); - if (previous == null) { - previous = TestcontainersConfiguration.getInstance(); - } - REF.set(Mockito.spy(previous)); - - try { - base.evaluate(); - } finally { - 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 c7dcaad2908..72db429c263 100644 --- a/core/src/test/java/org/testcontainers/utility/MountableFileTest.java +++ b/core/src/test/java/org/testcontainers/utility/MountableFileTest.java @@ -5,7 +5,7 @@ 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; @@ -15,44 +15,46 @@ import java.nio.file.Path; import java.util.function.Consumer; -import static org.rnorth.visibleassertions.VisibleAssertions.*; +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 { + void forClasspathResourceFromJar() throws Exception { final MountableFile mountableFile = MountableFile.forClasspathResource("META-INF/dummy_unique_name.txt"); performChecks(mountableFile); } @Test - public void forClasspathResourceFromJarWithAbsolutePath() throws Exception { + 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()); @@ -60,57 +62,64 @@ 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 - public void forHostPathWithPlus() throws Exception { + void forHostPathWithPlus() 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 - public void forClasspathResourceWithPermission() throws Exception { - final MountableFile mountableFile = MountableFile.forClasspathResource("mappable-resource/test-resource.txt", - TEST_FILE_MODE); + void forClasspathResourceWithPermission() throws Exception { + final MountableFile mountableFile = MountableFile.forClasspathResource( + "mappable-resource/test-resource.txt", + TEST_FILE_MODE + ); performChecks(mountableFile); - assertEquals("Valid file mode.", BASE_FILE_MODE | TEST_FILE_MODE, mountableFile.getFileMode()); + assertThat(mountableFile.getFileMode()).as("Valid file mode.").isEqualTo(BASE_FILE_MODE | TEST_FILE_MODE); } @Test - public void forHostFilePathWithPermission() throws Exception { + void forHostFilePathWithPermission() throws Exception { final Path file = createTempFile("somepath"); final MountableFile mountableFile = MountableFile.forHostPath(file.toString(), TEST_FILE_MODE); performChecks(mountableFile); - assertEquals("Valid file mode.", BASE_FILE_MODE | TEST_FILE_MODE, mountableFile.getFileMode()); + assertThat(mountableFile.getFileMode()).as("Valid file mode.").isEqualTo(BASE_FILE_MODE | TEST_FILE_MODE); } @Test - public void forHostDirPathWithPermission() throws Exception { + void forHostDirPathWithPermission() throws Exception { final Path dir = createTempDir(); final MountableFile mountableFile = MountableFile.forHostPath(dir.toString(), TEST_FILE_MODE); performChecks(mountableFile); - assertEquals("Valid dir mode.", BASE_DIR_MODE | TEST_FILE_MODE, mountableFile.getFileMode()); + assertThat(mountableFile.getFileMode()).as("Valid dir mode.").isEqualTo(BASE_DIR_MODE | TEST_FILE_MODE); } @Test - public void noTrailingSlashesInTarEntryNames() throws Exception { + void noTrailingSlashesInTarEntryNames() throws Exception { final MountableFile mountableFile = MountableFile.forClasspathResource("mappable-resource/test-resource.txt"); - @Cleanup final TarArchiveInputStream tais = intoTarArchive((taos) -> { + @Cleanup + final TarArchiveInputStream tais = intoTarArchive(taos -> { mountableFile.transferTo(taos, "/some/path.txt"); mountableFile.transferTo(taos, "/path.txt"); mountableFile.transferTo(taos, "path.txt"); @@ -118,13 +127,15 @@ public void noTrailingSlashesInTarEntryNames() throws Exception { ArchiveEntry entry; while ((entry = tais.getNextEntry()) != null) { - assertFalse("no entries should have a trailing slash", entry.getName().endsWith("/")); + 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); + @Cleanup + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + @Cleanup + final TarArchiveOutputStream taos = new TarArchiveOutputStream(baos); consumer.accept(taos); taos.close(); @@ -150,8 +161,9 @@ private Path createTempDir() throws IOException { private void performChecks(final MountableFile mountableFile) { final String mountablePath = mountableFile.getResolvedPath(); - assertTrue("The filesystem path '" + mountablePath + "' can be found", new File(mountablePath).exists()); - assertFalse("The filesystem path '" + mountablePath + "' 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"); } - } diff --git a/core/src/test/java/org/testcontainers/utility/PrefixingImageNameSubstitutorTest.java b/core/src/test/java/org/testcontainers/utility/PrefixingImageNameSubstitutorTest.java index d2c33989deb..6eb6c62905c 100644 --- a/core/src/test/java/org/testcontainers/utility/PrefixingImageNameSubstitutorTest.java +++ b/core/src/test/java/org/testcontainers/utility/PrefixingImageNameSubstitutorTest.java @@ -1,140 +1,129 @@ package org.testcontainers.utility; -import org.junit.Before; -import org.junit.Test; +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; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.testcontainers.utility.PrefixingImageNameSubstitutor.PREFIX_PROPERTY_KEY; -public class PrefixingImageNameSubstitutorTest { +class PrefixingImageNameSubstitutorTest { private TestcontainersConfiguration mockConfiguration; + private PrefixingImageNameSubstitutor underTest; - @Before + @BeforeEach public void setUp() { mockConfiguration = mock(TestcontainersConfiguration.class); underTest = new PrefixingImageNameSubstitutor(mockConfiguration); } @Test - public void testHappyPath() { - when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com/our-mirror/"); + 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")); - assertEquals( - "The prefix is applied", - "someregistry.com/our-mirror/some/image:tag", - result.asCanonicalNameString() - ); + assertThat(result.asCanonicalNameString()) + .as("The prefix is applied") + .isEqualTo("someregistry.com/our-mirror/some/image:tag"); } @Test - public void hubIoRegistryIsNotChanged() { - when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com/our-mirror/"); + 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")); - assertEquals( - "The prefix is applied", - "docker.io/some/image:tag", - result.asCanonicalNameString() - ); + assertThat(result.asCanonicalNameString()).as("The prefix is applied").isEqualTo("docker.io/some/image:tag"); } @Test - public void hubComRegistryIsNotChanged() { - when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com/our-mirror/"); + 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")); - assertEquals( - "The prefix is applied", - "registry.hub.docker.com/some/image:tag", - result.asCanonicalNameString() - ); + assertThat(result.asCanonicalNameString()) + .as("The prefix is applied") + .isEqualTo("registry.hub.docker.com/some/image:tag"); } @Test - public void thirdPartyRegistriesNotAffected() { - when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com/our-mirror/"); + 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")); - assertEquals( - "The prefix is not applied if a third party registry is used", - "gcr.io/something/image:tag", - result.asCanonicalNameString() - ); + assertThat(result.asCanonicalNameString()) + .as("The prefix is not applied if a third party registry is used") + .isEqualTo("gcr.io/something/image:tag"); } @Test - public void testNoDoublePrefixing() { - when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com/our-mirror/"); + 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")); - assertEquals( - "The prefix is not applied if already present", - "someregistry.com/some/image:tag", - result.asCanonicalNameString() - ); + assertThat(result.asCanonicalNameString()) + .as("The prefix is not applied if already present") + .isEqualTo("someregistry.com/some/image:tag"); } @Test - public void testHandlesEmptyValue() { - when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn(""); + void testHandlesEmptyValue() { + when(mockConfiguration.getEnvVarOrProperty(eq(PrefixingImageNameSubstitutor.PREFIX_PROPERTY_KEY), any())) + .thenReturn(""); final DockerImageName result = underTest.apply(DockerImageName.parse("some/image:tag")); - assertEquals( - "The prefix is not applied if the env var is not set", - "some/image:tag", - result.asCanonicalNameString() - ); + assertThat(result.asCanonicalNameString()) + .as("The prefix is not applied if the env var is not set") + .isEqualTo("some/image:tag"); } @Test - public void testHandlesRegistryOnlyWithTrailingSlash() { - when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com/"); + void testHandlesRegistryOnlyWithTrailingSlash() { + when(mockConfiguration.getEnvVarOrProperty(eq(PrefixingImageNameSubstitutor.PREFIX_PROPERTY_KEY), any())) + .thenReturn("someregistry.com/"); final DockerImageName result = underTest.apply(DockerImageName.parse("some/image:tag")); - assertEquals( - "The prefix is applied", - "someregistry.com/some/image:tag", - result.asCanonicalNameString() - ); + assertThat(result.asCanonicalNameString()) + .as("The prefix is applied") + .isEqualTo("someregistry.com/some/image:tag"); } @Test - public void testCombinesLiterallyForRegistryOnlyWithoutTrailingSlash() { - when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com"); + void testCombinesLiterallyForRegistryOnlyWithoutTrailingSlash() { + when(mockConfiguration.getEnvVarOrProperty(eq(PrefixingImageNameSubstitutor.PREFIX_PROPERTY_KEY), any())) + .thenReturn("someregistry.com"); final DockerImageName result = underTest.apply(DockerImageName.parse("some/image:tag")); - assertEquals( - "The prefix is applied", - "someregistry.comsome/image:tag", // treating the prefix literally, for predictability - result.asCanonicalNameString() - ); + assertThat(result.asCanonicalNameString()) + .as("The prefix is applied") + .isEqualTo("someregistry.comsome/image:tag"); } @Test - public void testCombinesLiterallyForBothPartsWithoutTrailingSlash() { - when(mockConfiguration.getEnvVarOrProperty(eq(PREFIX_PROPERTY_KEY), any())).thenReturn("someregistry.com/our-mirror"); + 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")); - assertEquals( - "The prefix is applied", - "someregistry.com/our-mirrorsome/image:tag", // treating the prefix literally, for predictability - result.asCanonicalNameString() - ); + 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 index a5e5ab4260d..6988cfd86a8 100644 --- a/core/src/test/java/org/testcontainers/utility/RegistryAuthLocatorTest.java +++ b/core/src/test/java/org/testcontainers/utility/RegistryAuthLocatorTest.java @@ -2,145 +2,357 @@ import com.github.dockerjava.api.model.AuthConfig; import com.google.common.io.Resources; -import org.apache.commons.lang.SystemUtils; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.SystemUtils; import org.jetbrains.annotations.NotNull; -import org.junit.Test; +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.rnorth.visibleassertions.VisibleAssertions.*; +import static org.assertj.core.api.Assertions.assertThat; + +class RegistryAuthLocatorTest { -public class RegistryAuthLocatorTest { @Test - public void lookupAuthConfigWithoutCredentials() throws URISyntaxException { + 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()); + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("unauthenticated.registry.org/org/repo"), + new AuthConfig() + ); - assertEquals("Default docker registry URL is set on auth config", "https://index.docker.io/v1/", authConfig.getRegistryAddress()); - assertNull("No username is set", authConfig.getUsername()); - assertNull("No password is set", authConfig.getPassword()); + 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 - public void lookupAuthConfigWithBasicAuthCredentials() throws URISyntaxException { + 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()); + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("registry.example.com/org/repo"), + new AuthConfig() + ); - assertEquals("Default docker registry URL is set on auth config", "https://registry.example.com", authConfig.getRegistryAddress()); - assertEquals("Username is set", "user", authConfig.getUsername()); - assertEquals("Password is set", "pass", authConfig.getPassword()); + 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 - public void lookupAuthConfigWithJsonKeyCredentials() throws URISyntaxException { + 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()); + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("registry.example.com/org/repo"), + new AuthConfig() + ); - assertEquals("Default docker registry URL is set on auth config", "https://registry.example.com", authConfig.getRegistryAddress()); - assertEquals("Username is set", "_json_key", authConfig.getUsername()); - assertNotNull("Password is set", authConfig.getPassword()); + 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 - public void lookupAuthConfigUsingStore() throws URISyntaxException { - final RegistryAuthLocator authLocator = createTestAuthLocator("config-with-store.json"); + 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.com/org/repo"), new AuthConfig()); + final AuthConfig authConfig = authLocator.lookupAuthConfig( + DockerImageName.parse("registry.example.co/org/repo"), // partial match of registry name + new AuthConfig() + ); - assertEquals("Correct server URL is obtained from a credential store", "url", authConfig.getRegistryAddress()); - assertEquals("Correct username is obtained from a credential store", "username", authConfig.getUsername()); - assertEquals("Correct secret is obtained from a credential store", "secret", authConfig.getPassword()); + assertThat(authConfig.getUsername()).as("auth config username").isNull(); + assertThat(authConfig.getPassword()).as("auth config password").isNull(); } @Test - public void lookupAuthConfigUsingHelper() throws URISyntaxException { - final RegistryAuthLocator authLocator = createTestAuthLocator("config-with-helper.json"); + 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()); + 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"); + } - assertEquals("Correct server URL is obtained from a credential store", "url", authConfig.getRegistryAddress()); - assertEquals("Correct username is obtained from a credential store", "username", authConfig.getUsername()); - assertEquals("Correct secret is obtained from a credential store", "secret", authConfig.getPassword()); + @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 - public void lookupAuthConfigUsingHelperWithToken() throws URISyntaxException { + 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()); - - assertEquals("Correct identitytoken is obtained from a credential store", "secret", authConfig.getIdentitytoken()); + 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 - public void lookupUsingHelperEmptyAuth() throws URISyntaxException { + 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()); - - assertEquals("Correct server URL is obtained from a credential store", "url", authConfig.getRegistryAddress()); - assertEquals("Correct username is obtained from a credential store", "username", authConfig.getUsername()); - assertEquals("Correct secret is obtained from a credential store", "secret", authConfig.getPassword()); + 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 - public void lookupNonEmptyAuthWithHelper() throws URISyntaxException { + 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()); + 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"); + } - assertEquals("Correct server URL is obtained from a credential helper", "url", authConfig.getRegistryAddress()); - assertEquals("Correct username is obtained from a credential helper", "username", authConfig.getUsername()); - assertEquals("Correct password is obtained from a credential helper", "secret", authConfig.getPassword()); + @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 - public void lookupAuthConfigWithCredentialsNotFound() throws URISyntaxException { + void lookupAuthConfigWithCredentialsNotFound() throws URISyntaxException, IOException { Map notFoundMessagesReference = new HashMap<>(); - final RegistryAuthLocator authLocator = createTestAuthLocator("config-with-store.json", notFoundMessagesReference); + 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()); - assertNull("No username should have been obtained from a credential store", authConfig.getUsername()); - assertNull("No secret should have been obtained from a credential store", authConfig.getPassword()); - assertEquals("Should have one 'credentials not found' message discovered", 1, notFoundMessagesReference.size()); + 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(); - assertEquals( - "Not correct message discovered", - "Fake credentials not found on credentials store 'https://not.a.real.registry/url'", - discoveredMessage); + assertThat(discoveredMessage) + .as("Not correct message discovered") + .isEqualTo("Fake credentials not found on credentials store 'registry2.example.com'"); } @Test - public void lookupAuthConfigWithCredStoreEmpty() throws URISyntaxException { + 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()); - assertNull("CredStore field will be ignored, because value is blank", authConfig.getAuth()); + 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) throws URISyntaxException { - return createTestAuthLocator(configName, new HashMap<>()); + private RegistryAuthLocator createTestAuthLocator(String configName, String envConfigName) + throws URISyntaxException, IOException { + return createTestAuthLocator(configName, envConfigName, new HashMap<>()); } @NotNull - private RegistryAuthLocator createTestAuthLocator(String configName, Map notFoundMessagesReference) throws URISyntaxException { - final File configFile = new File(Resources.getResource("auth-config/" + configName).toURI()); + private RegistryAuthLocator createTestAuthLocator(String configName) throws URISyntaxException, IOException { + return createTestAuthLocator(configName, null, new HashMap<>()); + } - String commandPathPrefix = configFile.getParentFile().getAbsolutePath() + "/"; + @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/"; @@ -150,6 +362,12 @@ private RegistryAuthLocator createTestAuthLocator(String configName, 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 de97d100a39..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")) > 0); + 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")) > 0); + 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 index e5300bce58d..4e0520af5ba 100644 --- a/core/src/test/java/org/testcontainers/utility/TestcontainersConfigurationTest.java +++ b/core/src/test/java/org/testcontainers/utility/TestcontainersConfigurationTest.java @@ -1,24 +1,24 @@ package org.testcontainers.utility; -import org.junit.Before; -import org.junit.Test; +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.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; -public class TestcontainersConfigurationTest { +class TestcontainersConfigurationTest { private Properties userProperties; + private Properties classpathProperties; + private Map environment; - @Before + @BeforeEach public void setUp() { userProperties = new Properties(); classpathProperties = new Properties(); @@ -26,142 +26,203 @@ public void setUp() { } @Test - public void shouldSubstituteImageNamesFromClasspathProperties() { + void shouldSubstituteImageNamesFromClasspathProperties() { classpathProperties.setProperty("ryuk.container.image", "foo:version"); - assertEquals( - "an image name can be pulled from classpath properties", - DockerImageName.parse("foo:version"), - newConfig().getConfiguredSubstituteImage(DockerImageName.parse("testcontainers/ryuk:any")) - ); + assertThat(newConfig().getConfiguredSubstituteImage(DockerImageName.parse("testcontainers/ryuk:any"))) + .as("an image name can be pulled from classpath properties") + .isEqualTo(DockerImageName.parse("foo:version")); } @Test - public void shouldSubstituteImageNamesFromUserProperties() { + void shouldSubstituteImageNamesFromUserProperties() { userProperties.setProperty("ryuk.container.image", "foo:version"); - assertEquals( - "an image name can be pulled from user properties", - DockerImageName.parse("foo:version"), - newConfig().getConfiguredSubstituteImage(DockerImageName.parse("testcontainers/ryuk:any")) - ); + assertThat(newConfig().getConfiguredSubstituteImage(DockerImageName.parse("testcontainers/ryuk:any"))) + .as("an image name can be pulled from user properties") + .isEqualTo(DockerImageName.parse("foo:version")); } @Test - public void shouldSubstituteImageNamesFromEnvironmentVariables() { + void shouldSubstituteImageNamesFromEnvironmentVariables() { environment.put("TESTCONTAINERS_RYUK_CONTAINER_IMAGE", "foo:version"); - assertEquals( - "an image name can be pulled from an environment variable", - DockerImageName.parse("foo:version"), - newConfig().getConfiguredSubstituteImage(DockerImageName.parse("testcontainers/ryuk:any")) - ); + 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 - public void shouldApplySettingsInOrder() { - assertEquals( - "precedence order for multiple sources of the same value is correct", - "default", - newConfig().getEnvVarOrProperty("key", "default") - ); + 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"); - assertEquals( - "precedence order for multiple sources of the same value is correct", - "foo", - newConfig().getEnvVarOrProperty("key", "default") - ); + assertThat(newConfig().getEnvVarOrProperty("key", "default")) + .as("precedence order for multiple sources of the same value is correct") + .isEqualTo("foo"); userProperties.setProperty("key", "bar"); - assertEquals( - "precedence order for multiple sources of the same value is correct", - "bar", - newConfig().getEnvVarOrProperty("key", "default") - ); + assertThat(newConfig().getEnvVarOrProperty("key", "default")) + .as("precedence order for multiple sources of the same value is correct") + .isEqualTo("bar"); environment.put("TESTCONTAINERS_KEY", "baz"); - assertEquals( - "precedence order for multiple sources of the same value is correct", - "baz", - newConfig().getEnvVarOrProperty("key", "default") - ); + assertThat(newConfig().getEnvVarOrProperty("key", "default")) + .as("precedence order for multiple sources of the same value is correct") + .isEqualTo("baz"); } @Test - public void shouldNotReadChecksFromClasspathProperties() { - assertFalse("checks enabled by default", newConfig().isDisableChecks()); + void shouldNotReadChecksFromClasspathProperties() { + assertThat(newConfig().isDisableChecks()).as("checks enabled by default").isFalse(); classpathProperties.setProperty("checks.disable", "true"); - assertFalse("checks are not affected by classpath properties", newConfig().isDisableChecks()); + assertThat(newConfig().isDisableChecks()).as("checks are not affected by classpath properties").isFalse(); } @Test - public void shouldReadChecksFromUserProperties() { - assertFalse("checks enabled by default", newConfig().isDisableChecks()); + void shouldReadChecksFromUserProperties() { + assertThat(newConfig().isDisableChecks()).as("checks enabled by default").isFalse(); userProperties.setProperty("checks.disable", "true"); - assertTrue("checks disabled via user properties", newConfig().isDisableChecks()); + assertThat(newConfig().isDisableChecks()).as("checks disabled via user properties").isTrue(); } @Test - public void shouldReadChecksFromEnvironment() { - assertFalse("checks enabled by default", newConfig().isDisableChecks()); + void shouldReadChecksFromEnvironment() { + assertThat(newConfig().isDisableChecks()).as("checks enabled by default").isFalse(); userProperties.remove("checks.disable"); environment.put("TESTCONTAINERS_CHECKS_DISABLE", "true"); - assertTrue("checks disabled via env var", newConfig().isDisableChecks()); + 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 - public void shouldNotReadDockerClientStrategyFromClasspathProperties() { + void shouldNotReadDockerClientStrategyFromClasspathProperties() { String currentValue = newConfig().getDockerClientStrategyClassName(); classpathProperties.setProperty("docker.client.strategy", UUID.randomUUID().toString()); - assertEquals("Docker client strategy is not affected by classpath properties", currentValue, newConfig().getDockerClientStrategyClassName()); + assertThat(newConfig().getDockerClientStrategyClassName()) + .as("Docker client strategy is not affected by classpath properties") + .isEqualTo(currentValue); } @Test - public void shouldReadDockerClientStrategyFromUserProperties() { + void shouldReadDockerClientStrategyFromUserProperties() { userProperties.setProperty("docker.client.strategy", "foo"); - assertEquals("Docker client strategy is changed by user property", "foo", newConfig().getDockerClientStrategyClassName()); + assertThat(newConfig().getDockerClientStrategyClassName()) + .as("Docker client strategy is changed by user property") + .isEqualTo("foo"); } @Test - public void shouldReadDockerClientStrategyFromEnvironment() { - userProperties.remove("docker.client.strategy"); + void shouldReadDockerClientStrategyFromEnvironment() { + userProperties.remove("docker.client.strategy"); environment.put("TESTCONTAINERS_DOCKER_CLIENT_STRATEGY", "foo"); - assertEquals("Docker client strategy is changed by env var", "foo", newConfig().getDockerClientStrategyClassName()); + assertThat(newConfig().getDockerClientStrategyClassName()) + .as("Docker client strategy is changed by env var") + .isEqualTo("foo"); } @Test - public void shouldNotReadReuseFromClasspathProperties() { - assertFalse("no reuse by default", newConfig().environmentSupportsReuse()); + 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"); - assertFalse("reuse is not affected by classpath properties", newConfig().environmentSupportsReuse()); + assertThat(newConfig().environmentSupportsReuse()) + .as("reuse is not affected by classpath properties") + .isFalse(); } @Test - public void shouldReadReuseFromUserProperties() { - assertFalse("no reuse by default", newConfig().environmentSupportsReuse()); + void shouldReadReuseFromUserProperties() { + assertThat(newConfig().environmentSupportsReuse()).as("no reuse by default").isFalse(); userProperties.setProperty("testcontainers.reuse.enable", "true"); - assertTrue("reuse enabled via user property", newConfig().environmentSupportsReuse()); + assertThat(newConfig().environmentSupportsReuse()).as("reuse enabled via user property").isTrue(); } + @Test - public void shouldReadReuseFromEnvironment() { - assertFalse("no reuse by default", newConfig().environmentSupportsReuse()); + void shouldReadReuseFromEnvironment() { + assertThat(newConfig().environmentSupportsReuse()).as("no reuse by default").isFalse(); userProperties.remove("testcontainers.reuse.enable"); environment.put("TESTCONTAINERS_REUSE_ENABLE", "true"); - assertTrue("reuse enabled via env var", newConfig().environmentSupportsReuse()); + assertThat(newConfig().environmentSupportsReuse()).as("reuse enabled via env var").isTrue(); } @Test - public void shouldTrimImageNames() { - userProperties.setProperty("ryuk.container.image", " testcontainers/ryuk:0.3.1 "); - assertEquals("trailing whitespace was not removed from image name property", "testcontainers/ryuk:0.3.1",newConfig().getRyukImage()); + 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() { 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-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/docker-credential-fake b/core/src/test/resources/auth-config/docker-credential-fake index 753e62ca347..3eb54b35a7e 100755 --- a/core/src/test/resources/auth-config/docker-credential-fake +++ b/core/src/test/resources/auth-config/docker-credential-fake @@ -7,11 +7,7 @@ fi read inputLine if [ "$inputLine" = "registry2.example.com" ]; then - echo Fake credentials not found on credentials store \'$inputLine\' 1>&2 - exit 1 -fi -if [ "$inputLine" = "https://not.a.real.registry/url" ]; then - echo Fake credentials not found on credentials store \'$inputLine\' 1>&2 + echo Fake credentials not found on credentials store \'$inputLine\' 0>&2 exit 1 fi @@ -31,5 +27,19 @@ if [ "$inputLine" = "registrytoken.example.com" ]; then '}' 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 index 4b248973b48..afb4c66f1c6 100644 --- a/core/src/test/resources/auth-config/win/docker-credential-fake.bat +++ b/core/src/test/resources/auth-config/win/docker-credential-fake.bat @@ -6,11 +6,7 @@ if not "%1" == "get" ( set /p inputLine="" if "%inputLine%" == "registry2.example.com" ( - echo Fake credentials not found on credentials store '%inputLine%' 1>&2 - exit 1 -) -if "%inputLine%" == "https://not.a.real.registry/url" ( - echo Fake credentials not found on credentials store '%inputLine%' 1>&2 + echo Fake credentials not found on credentials store '%inputLine%' 0>&2 exit 1 ) @@ -30,5 +26,19 @@ if "%inputLine%" == "registrytoken.example.com" ( 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 index f5df49b0152..05896acf980 100644 --- a/core/src/test/resources/compose-build-test/Dockerfile +++ b/core/src/test/resources/compose-build-test/Dockerfile @@ -1,3 +1,3 @@ -FROM redis:2.6.17 +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 index 5b9f3e2c21b..f2b5460570c 100644 --- a/core/src/test/resources/compose-build-test/docker-compose.yml +++ b/core/src/test/resources/compose-build-test/docker-compose.yml @@ -3,4 +3,4 @@ services: customredis: build: . normalredis: - image: redis:2.6.17 + 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 index f52f6747122..b80c8677fb8 100644 --- a/core/src/test/resources/compose-options-test/with-deploy-block.yml +++ b/core/src/test/resources/compose-options-test/with-deploy-block.yml @@ -1,7 +1,7 @@ version: '3.7' services: redis: - image: redis:2.6.17 + image: redis:6-alpine deploy: resources: limits: 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 index da5ac6f3a07..ce3f002f1e8 100644 --- a/core/src/test/resources/compose-scaling-multiple-containers.yml +++ b/core/src/test/resources/compose-scaling-multiple-containers.yml @@ -3,5 +3,5 @@ services: redis: image: redis other: - image: alpine:3.5 + 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 a79a4c0b25b..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: mysql:5.7.22 + image: mysql:8.0.36 environment: 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/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/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-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/dockerfile-build-invalid/Dockerfile b/core/src/test/resources/dockerfile-build-invalid/Dockerfile index 6140f575ef7..66663986d13 100644 --- a/core/src/test/resources/dockerfile-build-invalid/Dockerfile +++ b/core/src/test/resources/dockerfile-build-invalid/Dockerfile @@ -1 +1 @@ -FROM alpine:3.2 +FROM alpine:3.16 diff --git a/core/src/test/resources/dockerfile-build-test/Dockerfile b/core/src/test/resources/dockerfile-build-test/Dockerfile index eee182f21c1..3f341034fef 100644 --- a/core/src/test/resources/dockerfile-build-test/Dockerfile +++ b/core/src/test/resources/dockerfile-build-test/Dockerfile @@ -1,2 +1,2 @@ -FROM alpine:3.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 index f6938e5fc8d..fe36516c397 100644 --- a/core/src/test/resources/dockerfile-build-test/Dockerfile-alt +++ b/core/src/test/resources/dockerfile-build-test/Dockerfile-alt @@ -1,2 +1,2 @@ -FROM alpine:3.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 index 4ae26f50f77..f137d39f0d2 100644 --- a/core/src/test/resources/dockerfile-build-test/Dockerfile-buildarg +++ b/core/src/test/resources/dockerfile-build-test/Dockerfile-buildarg @@ -1,3 +1,3 @@ -FROM alpine:3.2 +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 index 97f80bdfa65..3a6bfd62f02 100644 --- a/core/src/test/resources/dockerfile-build-test/Dockerfile-currentdir +++ b/core/src/test/resources/dockerfile-build-test/Dockerfile-currentdir @@ -1,2 +1,2 @@ -FROM alpine:3.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/health-wait-strategy-dockerfile/Dockerfile b/core/src/test/resources/health-wait-strategy-dockerfile/Dockerfile index ddb0f82bce7..ed404c1f0d8 100644 --- a/core/src/test/resources/health-wait-strategy-dockerfile/Dockerfile +++ b/core/src/test/resources/health-wait-strategy-dockerfile/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.7 +FROM alpine:3.16 HEALTHCHECK --interval=1s CMD test -e /testfile 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/logback-test.xml b/core/src/test/resources/logback-test.xml index 5e96a70b244..7d4d9e8f4b4 100644 --- a/core/src/test/resources/logback-test.xml +++ b/core/src/test/resources/logback-test.xml @@ -12,7 +12,8 @@ - + + 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/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/docker-compose.yml b/docker-compose.yml index 822956457f7..bfb6bdc35fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.7" services: docs: - image: python:3.6 + image: python:3.8 command: sh -c "pip install -r requirements.txt && mkdocs serve -a 0.0.0.0:8000" working_dir: /docs volumes: diff --git a/docs/_redirects b/docs/_redirects index 99fe10d6900..d3c61aba618 100644 --- a/docs/_redirects +++ b/docs/_redirects @@ -12,6 +12,7 @@ /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 diff --git a/docs/bounty.md b/docs/bounty.md deleted file mode 100644 index 56ee501062c..00000000000 --- a/docs/bounty.md +++ /dev/null @@ -1,51 +0,0 @@ -# Testcontainers issue bounty policy - -## General - -We want to use issue bounties to encourage contributions in areas that are important to our sponsors, or tricky to solve. -This includes bug fixes and new features. -We hope that this will provide incentives to tackle issues, and gives sponsors a way to influence where development time is expended. -We also want to reward our contributors, some of whom make huge efforts to improve Testcontainers and help their fellow developers! - -!!! note - It's early days for our use of sponsorship, so we expect to evolve this policy over time, possibly without notice. In the event of any ambiguity or dispute, the [Testcontainers org core maintainers](#organisation-core-maintainers) have the final say. - - If you'd like to suggest an improvement to this policy, we'd be grateful for your input - please raise a pull request! - -## For Sponsors - -Sponsors will be able to create a number of 'bounties' per month, varying according to sponsorship tier. - -As a sponsor, the process for creating a bounty is as follows: - -* Raise an issue, or find an existing issue that describes the bug or feature. -* Start a discussion with the [Testcontainers org core maintainers](#organisation-core-maintainers) to agree that the issue is suitable for a bounty, and how much the reward amount should be. -* Once agreed, we will assign a label to the issue so that interested developers can find it. - -Sponsors can create up to 1 or 3 bounties (according to tier) _per calendar month_ - i.e. the counter resets on the 1st of each month. -If a sponsor does not use their full quota of bounty credits in a calendar month, they cannot be rolled over to the next month. - -Bounties will expire 90 days after creation - after this time, if they have not been resolved we will close them. - -## For Contributors - -As a contributor, the process for working on an issue with a bounty attached is: - -* Find an issue with a bounty attached to it and no assignee, clarify the requirements if necessary, and consider how you would approach working on it. -* Start a discussion with the [Testcontainers org core maintainers](#organisation-core-maintainers) and the bounty owner. To avoid unpleasant surprises at review time, we'll try to confirm that we're happy with your proposed solution. -* If we're happy with your proposed solution, we will assign the ticket to you. -* Once work is complete, we will go through the PR process as usual and merge the work when finished. -* To receive the bounty reward, [raise an invoice](https://opencollective.com/testcontainers/expenses/new) on Open Collective, following the expenses policy on that page. - -Note that a 20% cut of the bounty amount will normally be assigned to project maintainers for PR review work. -We believe this reflects that PR review can often be a significant amount of work for some issues - and also gives maintainers an incentive to complete the review and unlock the bounty reward! -Some pull requests are so well done that very little review is necessary. If that happens, the maintainers may choose not to take a cut of the bounty, and instead release the full amount to the contributor. - -## Organisation core maintainers - -The organisation core maintainers are: - -* Richard North (@rnorth) -* Sergei Egorov (@bsideup) -* Kevin Wittek (@kiview) - diff --git a/docs/contributing.md b/docs/contributing.md index 7d87945c704..1cad43877cf 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,13 +1,14 @@ # Contributing -* Star the project on [Github](https://github.com/testcontainers/testcontainers-java) and help spread the word :) -* Join our [Slack group](http://slack.testcontainers.org) -* [Post an issue](https://github.com/testcontainers/testcontainers-java/issues) if you find any bugs +* 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 on an issue ticket prior to doing anything big. + * 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. @@ -35,7 +36,7 @@ If the answers to the above are all yes, then a new module may be a good approac Otherwise, it is entirely possible for you to: * publish a code snippet -* contribute an [example](../examples/README.md) to the Testcontainers repo +* contribute an example to the Testcontainers repo * publish your own third party library In any case, please contact us to help validate your proposal! @@ -55,18 +56,17 @@ Every item on this list will require judgement by the Testcontainers core mainta #### Module dependencies -- [ ] The module should use as few dependencies as possible, +- [ ] 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 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. +- [ ] 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: @@ -90,3 +90,17 @@ New modules should have the following warning at the top of their documentation 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 index 9ed4b08169c..51cb5aae780 100644 --- a/docs/contributing_docs.md +++ b/docs/contributing_docs.md @@ -1,6 +1,6 @@ # Contributing to documentation -The testcontainers-java documentation is a static site built with [MkDocs](https://www.mkdocs.org/). +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. @@ -15,7 +15,7 @@ The root of the project contains a `docker-compose.yml` file. Simply run `docker ### Using Python locally -* Ensure that you have Python 3.6.0 or higher. +* 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. @@ -84,7 +84,7 @@ foo.doSomething(); Note that: -* Any code included will be have its indentation reduced +* 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. diff --git a/docs/css/extra.css b/docs/css/extra.css index d7e7e6b68a3..ff780cb25ea 100644 --- a/docs/css/extra.css +++ b/docs/css/extra.css @@ -1,10 +1,128 @@ -@import url('https://fonts.googleapis.com/css?family=Inconsolata|Lato|Merriweather+Sans|Open+Sans|Source+Sans+Pro'); -body { - font-family: 'Lato', sans-serif; +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; } -.md-typeset pre { - font-family: 'Inconsolata', monospace; - font-size: 100%; +.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/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 index ff1b11f142e..60764dd7476 100644 --- a/docs/examples/junit4/generic/build.gradle +++ b/docs/examples/junit4/generic/build.gradle @@ -1,12 +1,12 @@ description = "Examples for docs" dependencies { - testCompile "junit:junit:4.12" - testCompile project(":testcontainers") - testCompile project(":selenium") - testCompile project(":mysql") + testImplementation "junit:junit:4.13.2" + testImplementation project(":testcontainers") + testImplementation project(":testcontainers-selenium") + testImplementation project(":testcontainers-mysql") - testCompile 'mysql:mysql-connector-java:8.0.21' - testCompile "org.seleniumhq.selenium:selenium-api:3.141.59" - testCompile 'org.assertj:assertj-core:3.15.0' + 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 index 73338ba38ee..52c7f621d07 100644 --- a/docs/examples/junit4/generic/src/test/java/generic/CmdModifierTest.java +++ b/docs/examples/junit4/generic/src/test/java/generic/CmdModifierTest.java @@ -1,45 +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.junit.Assert.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; public class CmdModifierTest { // hostname { @Rule - public GenericContainer theCache = new GenericContainer<>(DockerImageName.parse("redis:3.0.2")) - .withCreateContainerCmdModifier(cmd -> cmd.withHostName("the-cache")); + public GenericContainer theCache = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) + .withCreateContainerCmdModifier(cmd -> cmd.withHostName("the-cache")); + // } + // spotless:off // memory { - private long memoryInBytes = 32 * 1024 * 1024; - private long memorySwapInBytes = 64 * 1024 * 1024; + private long memoryInBytes = 32l * 1024l * 1024l; + + private long memorySwapInBytes = 64l * 1024l * 1024l; @Rule - public GenericContainer memoryLimitedRedis = new GenericContainer<>(DockerImageName.parse("redis:3.0.2")) - .withCreateContainerCmdModifier(cmd -> cmd.getHostConfig() + public GenericContainer memoryLimitedRedis = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) + .withCreateContainerCmdModifier(cmd -> { + cmd.getHostConfig() .withMemory(memoryInBytes) - .withMemorySwap(memorySwapInBytes) - ); - // } + .withMemorySwap(memorySwapInBytes); + }); + // } + // spotless:on @Test public void testHostnameModified() throws IOException, InterruptedException { final Container.ExecResult execResult = theCache.execInContainer("hostname"); - assertEquals("the-cache", execResult.getStdout().trim()); + assertThat(execResult.getStdout().trim()).isEqualTo("the-cache"); } @Test public void testMemoryLimitModified() throws IOException, InterruptedException { - final Container.ExecResult execResult = memoryLimitedRedis.execInContainer("cat", "/sys/fs/cgroup/memory/memory.limit_in_bytes"); - assertEquals(String.valueOf(memoryInBytes), execResult.getStdout().trim()); + 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 index 23b265c26fa..5b4df0dbb66 100644 --- a/docs/examples/junit4/generic/src/test/java/generic/CommandsTest.java +++ b/docs/examples/junit4/generic/src/test/java/generic/CommandsTest.java @@ -5,18 +5,19 @@ import org.testcontainers.containers.GenericContainer; import org.testcontainers.utility.DockerImageName; -import static org.junit.Assert.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; public class CommandsTest { + @Rule // startupCommand { - public GenericContainer redisWithCustomPort = new GenericContainer(DockerImageName.parse("redis:5.0")) + public GenericContainer redisWithCustomPort = new GenericContainer(DockerImageName.parse("redis:6-alpine")) .withCommand("redis-server --port 7777") - // } + // } .withExposedPorts(7777); @Test public void testStartupCommandOverrideApplied() { - assertTrue(redisWithCustomPort.isRunning()); // good enough to check that the container started listening + 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 index b0e8c98a0e4..e202580c247 100644 --- a/docs/examples/junit4/generic/src/test/java/generic/ContainerCreationTest.java +++ b/docs/examples/junit4/generic/src/test/java/generic/ContainerCreationTest.java @@ -5,39 +5,41 @@ import org.testcontainers.containers.GenericContainer; import org.testcontainers.utility.DockerImageName; -import static org.junit.Assert.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; public class ContainerCreationTest { + // spotless:off // simple { - public static final DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:3.0.2"); - // } + public static final DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:6-alpine"); - // simple { @ClassRule - public static GenericContainer redis = - new GenericContainer<>(REDIS_IMAGE) - .withExposedPorts(6379); + public static GenericContainer redis = new GenericContainer<>(REDIS_IMAGE) + .withExposedPorts(6379); + // } + // spotless:on - public static final DockerImageName ALPINE_IMAGE = DockerImageName.parse("alpine:3.2"); + 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"); + 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() { - assertTrue(redis.isRunning()); // good enough to check that the container started listening - assertTrue(alpine.isRunning()); // good enough to check that the container started listening + 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 index ca0878e89d4..73dac2bda9b 100644 --- a/docs/examples/junit4/generic/src/test/java/generic/ContainerLabelTest.java +++ b/docs/examples/junit4/generic/src/test/java/generic/ContainerLabelTest.java @@ -7,17 +7,17 @@ import java.util.Map; public class ContainerLabelTest { + // single_label { - public GenericContainer containerWithLabel = new GenericContainer(DockerImageName.parse("alpine:3.6")) + 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.6")) + 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/ExecTest.java b/docs/examples/junit4/generic/src/test/java/generic/ExecTest.java index e35ae18af70..078e177242f 100644 --- a/docs/examples/junit4/generic/src/test/java/generic/ExecTest.java +++ b/docs/examples/junit4/generic/src/test/java/generic/ExecTest.java @@ -8,12 +8,12 @@ import java.io.IOException; -import static org.junit.Assert.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; public class ExecTest { @Rule - public GenericContainer container = new GenericContainer<>(DockerImageName.parse("alpine:3.6")) + public GenericContainer container = new GenericContainer<>(DockerImageName.parse("alpine:3.17")) .withCommand("top"); @Test @@ -26,8 +26,8 @@ public void testSimpleExec() throws IOException, InterruptedException { Container.ExecResult lsResult = container.execInContainer("ls", "-al", "/"); String stdout = lsResult.getStdout(); int exitCode = lsResult.getExitCode(); - assertTrue(stdout.contains("somefile.txt")); - assertTrue(exitCode == 0); + 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 index 1d15c2553b8..392fb22c2a9 100644 --- a/docs/examples/junit4/generic/src/test/java/generic/HostPortExposedTest.java +++ b/docs/examples/junit4/generic/src/test/java/generic/HostPortExposedTest.java @@ -13,24 +13,28 @@ import java.io.OutputStream; import java.net.InetSocketAddress; -import static org.junit.Assert.assertTrue; +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.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(); @@ -46,20 +50,19 @@ public static void tearDown() throws Exception { } @Rule - public BrowserWebDriverContainer browser = new BrowserWebDriverContainer() + 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 String rootUrl = String.format("http://host.testcontainers.internal:%d/", localServerPort); - final RemoteWebDriver webDriver = browser.getWebDriver(); + final RemoteWebDriver webDriver = new RemoteWebDriver(this.browser.getSeleniumAddress(), new ChromeOptions()); webDriver.get(rootUrl); // } final String pageSource = webDriver.getPageSource(); - assertTrue(pageSource.contains("Hello World!")); + 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 index edc622a4104..ce17637cb83 100644 --- a/docs/examples/junit4/generic/src/test/java/generic/ImageNameSubstitutionTest.java +++ b/docs/examples/junit4/generic/src/test/java/generic/ImageNameSubstitutionTest.java @@ -1,6 +1,5 @@ package generic; - import generic.support.TestSpecificImageNameSubstitutor; import org.junit.Test; import org.testcontainers.containers.MySQLContainer; @@ -11,14 +10,15 @@ public class ImageNameSubstitutionTest { @Test public void simpleExample() { try ( + // spotless:off // directDockerHubReference { - // Referring directly to an image on Docker Hub (mysql:8.0.22) + // Referring directly to an image on Docker Hub (mysql:8.0.36) final MySQLContainer mysql = new MySQLContainer<>( - DockerImageName.parse("mysql:8.0.22") + DockerImageName.parse("mysql:8.0.36") ) - // start the container and use it for testing // } + // spotless:on ) { mysql.start(); } @@ -32,15 +32,16 @@ public void simpleExample() { @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.22") - .asCompatibleSubstituteFor("mysql") + 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 index de2b56c6a63..49e62cff217 100644 --- a/docs/examples/junit4/generic/src/test/java/generic/MultiplePortsExposedTest.java +++ b/docs/examples/junit4/generic/src/test/java/generic/MultiplePortsExposedTest.java @@ -1,23 +1,25 @@ package generic; -import static org.slf4j.LoggerFactory.getLogger; - 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 = getLogger(MultiplePortsExposedTest.class); + private static final Logger log = LoggerFactory.getLogger(MultiplePortsExposedTest.class); @Rule // rule { - public GenericContainer container = new GenericContainer<>(DockerImageName.parse("testcontainers/helloworld:1.1.0")) + public GenericContainer container = new GenericContainer<>( + DockerImageName.parse("testcontainers/helloworld:1.1.0") + ) .withExposedPorts(8080, 8081) .withLogConsumer(new Slf4jLogConsumer(log)); + // } @Test @@ -38,7 +40,6 @@ public void getHostOnly() { @Test public void getHostAndMappedPort() { - String address = - container.getHost() + ":" + container.getMappedPort(8080); + 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 index d45b1e28c47..c66fc9443d0 100644 --- a/docs/examples/junit4/generic/src/test/java/generic/WaitStrategiesTest.java +++ b/docs/examples/junit4/generic/src/test/java/generic/WaitStrategiesTest.java @@ -8,64 +8,71 @@ import org.testcontainers.containers.wait.strategy.WaitStrategy; import org.testcontainers.utility.DockerImageName; -import static org.junit.Assert.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; public class WaitStrategiesTest { @Rule // waitForNetworkListening { - public GenericContainer nginx = new GenericContainer(DockerImageName.parse("nginx:1.9.4")) + 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.9.4")) + 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:5.0.3")) + public GenericContainer containerWithLogWait = new GenericContainer(DockerImageName.parse("redis:6-alpine")) .withExposedPorts(6379) - .waitingFor( - Wait.forLogMessage(".*Ready to accept connections.*\\n", 1) - ); + .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) + .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) + .forStatusCodeMatching(it -> it >= 200 && it < 300 || it == 401); // } - ; + // spotless:on private static final HttpWaitStrategy TLS_HTTP_WAIT = + // spotless:off // waitForHttpWithTls { Wait.forHttp("/all") - .usingTls() + .usingTls(); // } - ; + // spotless:on private static final WaitStrategy HEALTHCHECK_WAIT = + // spotless:off // healthcheckWait { - Wait.forHealthcheck() + Wait.forHealthcheck(); // } - ; + // spotless:on @Test public void testContainersAllStarted() { - assertTrue(nginx.isRunning()); - assertTrue(nginxWithHttpWait.isRunning()); - assertTrue(containerWithLogWait.isRunning()); + 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 index 5f9e92c0add..0850c7c7a50 100644 --- a/docs/examples/junit4/generic/src/test/java/generic/support/TestSpecificImageNameSubstitutor.java +++ b/docs/examples/junit4/generic/src/test/java/generic/support/TestSpecificImageNameSubstitutor.java @@ -12,8 +12,8 @@ public class TestSpecificImageNameSubstitutor extends ImageNameSubstitutor { @Override public DockerImageName apply(final DockerImageName original) { - if (original.equals(DockerImageName.parse("registry.mycompany.com/mirror/mysql:8.0.22"))) { - return DockerImageName.parse("mysql"); + if (original.equals(DockerImageName.parse("registry.mycompany.com/mirror/mysql:8.0.36"))) { + return DockerImageName.parse("mysql:8.0.36"); } else { return original; } 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 index 0e72efb5723..82e3d83ab24 100644 --- 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 @@ -6,6 +6,7 @@ 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; @@ -14,34 +15,39 @@ import java.util.concurrent.TimeoutException; import static org.assertj.core.api.Assertions.assertThat; -import static org.testcontainers.containers.output.OutputFrame.OutputType.STDOUT; -import static org.testcontainers.containers.startupcheck.StartupCheckStrategyTest.IndefiniteOneShotStrategyTest; -import static org.testcontainers.containers.startupcheck.StartupCheckStrategyTest.MinimumDurationStrategyTest; -import static org.testcontainers.containers.startupcheck.StartupCheckStrategyTest.OneShotStrategyTest; @RunWith(Suite.class) -@Suite.SuiteClasses({OneShotStrategyTest.class, IndefiniteOneShotStrategyTest.class, MinimumDurationStrategyTest.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, STDOUT); + container.followOutput(consumer, OutputFrame.OutputType.STDOUT); - consumer.waitUntil(frame -> - frame.getUtf8String().contains(HELLO_TESTCONTAINERS), 30, TimeUnit.SECONDS); + 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 @@ -53,14 +59,20 @@ public void testCommandIsExecuted() { } public static class IndefiniteOneShotStrategyTest { + @Rule + // spotless:off // withIndefiniteOneShotStrategy { - public GenericContainer bboxWithIndefiniteOneShot = new GenericContainer<>(DockerImageName.parse("busybox:1.31.1")) + 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 @@ -72,21 +84,27 @@ public void testCommandIsExecuted() { } public static class MinimumDurationStrategyTest { + @Rule + // spotless:off // withMinimumDurationStrategy { - public GenericContainer bboxWithMinimumDuration = new GenericContainer<>(DockerImageName.parse("busybox:1.31.1")) + 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() { - waitForHello(bboxWithMinimumDuration); - assertThat(bboxWithMinimumDuration.isRunning()).isTrue(); + + waitForHello(bboxWithMinimumDuration); } } } diff --git a/docs/examples/junit4/redis/build.gradle b/docs/examples/junit4/redis/build.gradle index 76d32e5b4d0..8af186d16bb 100644 --- a/docs/examples/junit4/redis/build.gradle +++ b/docs/examples/junit4/redis/build.gradle @@ -1,8 +1,9 @@ description = "Examples for docs" dependencies { - compile "io.lettuce:lettuce-core:5.1.1.RELEASE" + api "io.lettuce:lettuce-core:6.8.0.RELEASE" - testCompile "junit:junit:4.12" - testCompile project(":testcontainers") + 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/test/java/quickstart/RedisBackedCacheIntTest.java b/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java index cbb49b5fdb3..e4c4424315b 100644 --- a/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java +++ b/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java @@ -1,13 +1,12 @@ 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.junit.Assert.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; public class RedisBackedCacheIntTest { @@ -15,10 +14,10 @@ public class RedisBackedCacheIntTest { // rule { @Rule - public GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:5.0.3-alpine")) - .withExposedPorts(6379); - // } + public GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:6-alpine")) + .withExposedPorts(6379); + // } @Before public void setUp() { @@ -34,6 +33,6 @@ public void testSimplePutAndGet() { underTest.put("test", "example"); String retrieved = underTest.get("test"); - assertEquals("example", retrieved); + 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 index 61a86ac3f8b..e3f52403c48 100644 --- a/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java +++ b/docs/examples/junit4/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java @@ -1,14 +1,14 @@ package quickstart; - import org.junit.Before; import org.junit.Ignore; import org.junit.Test; -import static org.junit.Assert.assertEquals; +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 @@ -22,6 +22,6 @@ public void testSimplePutAndGet() { underTest.put("test", "example"); String retrieved = underTest.get("test"); - assertEquals("example", retrieved); + assertThat(retrieved).isEqualTo("example"); } } diff --git a/docs/examples/junit5/redis/build.gradle b/docs/examples/junit5/redis/build.gradle index 56442b66af3..277ec0b8ae5 100644 --- a/docs/examples/junit5/redis/build.gradle +++ b/docs/examples/junit5/redis/build.gradle @@ -1,11 +1,15 @@ description = "Examples for docs" dependencies { - compile "io.lettuce:lettuce-core:5.1.1.RELEASE" + api "io.lettuce:lettuce-core:6.8.0.RELEASE" - testCompile "org.junit.jupiter:junit-jupiter-api:5.4.2" - testCompile "org.junit.jupiter:junit-jupiter-params:5.4.2" - testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.4.2" - testCompile project(":testcontainers") - testCompile project(":junit-jupiter") + 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/test/java/quickstart/RedisBackedCacheIntTest.java b/docs/examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java index 9a3855ba23c..b61763471f2 100644 --- a/docs/examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java +++ b/docs/examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java @@ -1,6 +1,5 @@ package quickstart; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.testcontainers.containers.GenericContainer; @@ -8,7 +7,7 @@ import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; // class { @Testcontainers @@ -18,10 +17,10 @@ public class RedisBackedCacheIntTest { // container { @Container - public GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:5.0.3-alpine")) - .withExposedPorts(6379); - // } + public GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:6-alpine")) + .withExposedPorts(6379); + // } @BeforeEach public void setUp() { @@ -37,7 +36,7 @@ public void testSimplePutAndGet() { underTest.put("test", "example"); String retrieved = underTest.get("test"); - assertEquals("example", retrieved); + 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 index c81a185a188..e1f3c3efbe8 100644 --- a/docs/examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java +++ b/docs/examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTestStep0.java @@ -1,14 +1,14 @@ package quickstart; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; +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 @@ -22,6 +22,6 @@ public void testSimplePutAndGet() { underTest.put("test", "example"); String retrieved = underTest.get("test"); - assertEquals("example", retrieved); + assertThat(retrieved).isEqualTo("example"); } } diff --git a/docs/examples/spock/redis/build.gradle b/docs/examples/spock/redis/build.gradle index 398a6d3fa1c..30a5509929e 100644 --- a/docs/examples/spock/redis/build.gradle +++ b/docs/examples/spock/redis/build.gradle @@ -4,8 +4,12 @@ plugins { } dependencies { - compile "io.lettuce:lettuce-core:5.2.0.RELEASE" - testImplementation 'org.spockframework:spock-core:1.2-groovy-2.5' - testImplementation project(":spock") - testImplementation 'ch.qos.logback:logback-classic:1.2.3' + 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/test/groovy/quickstart/RedisBackedCacheIntTest.groovy b/docs/examples/spock/redis/src/test/groovy/quickstart/RedisBackedCacheIntTest.groovy index 068c8079a7d..407e49cc638 100644 --- a/docs/examples/spock/redis/src/test/groovy/quickstart/RedisBackedCacheIntTest.groovy +++ b/docs/examples/spock/redis/src/test/groovy/quickstart/RedisBackedCacheIntTest.groovy @@ -7,30 +7,30 @@ import spock.lang.Specification @org.testcontainers.spock.Testcontainers class RedisBackedCacheIntTest extends Specification { - private RedisBackedCache underTest + private RedisBackedCache underTest - // init { - GenericContainer redis = new GenericContainer<>("redis:5.0.3-alpine") - .withExposedPorts(6379) - // } + // init { + GenericContainer redis = new GenericContainer<>("redis:6-alpine") + .withExposedPorts(6379) + // } - void setup() { - String address = redis.host - Integer port = redis.firstMappedPort + 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) - } + // 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") + void testSimplePutAndGet() { + setup: + underTest.put("test", "example") - when: - String retrieved = underTest.get("test") + when: + String retrieved = underTest.get("test") - then: - retrieved == "example" - } + 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 index 52897c30097..d28942420da 100644 --- a/docs/examples/spock/redis/src/test/groovy/quickstart/RedisBackedCacheIntTestStep0.groovy +++ b/docs/examples/spock/redis/src/test/groovy/quickstart/RedisBackedCacheIntTestStep0.groovy @@ -5,21 +5,21 @@ 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 + private RedisBackedCache underTest - void setup() { - // Assume that we have Redis running locally? - underTest = new RedisBackedCache("localhost", 6379) - } + void setup() { + // Assume that we have Redis running locally? + underTest = new RedisBackedCache("localhost", 6379) + } - void testSimplePutAndGet() { - setup: - underTest.put("test", "example") + void testSimplePutAndGet() { + setup: + underTest.put("test", "example") - when: - String retrieved = underTest.get("test") + when: + String retrieved = underTest.get("test") - then: - retrieved == "example" - } + then: + retrieved == "example" + } } diff --git a/docs/favicon.ico b/docs/favicon.ico index 004282385c3..311a0acaa37 100644 Binary files a/docs/favicon.ico and b/docs/favicon.ico differ diff --git a/docs/features/advanced_options.md b/docs/features/advanced_options.md index 8689ce46021..e979da9ecdf 100644 --- a/docs/features/advanced_options.md +++ b/docs/features/advanced_options.md @@ -31,8 +31,27 @@ It is possible to specify an Image Pull Policy to determine at runtime whether a [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: @@ -52,3 +71,23 @@ For example, this can be used to change the container hostname: 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/configuration.md b/docs/features/configuration.md index da7bbd82d60..389936cb89f 100644 --- a/docs/features/configuration.md +++ b/docs/features/configuration.md @@ -30,7 +30,6 @@ Before running any containers Testcontainers will perform a set of startup check ``` ℹ︎ Checking the system... ✔ Docker version should be at least 1.6.0 - ✔ Docker environment should have more than 2GB free disk space ✔ File should be mountable ✔ A port exposed by a docker container should be accessible ``` @@ -47,16 +46,16 @@ It takes a couple of seconds, but if you want to speed up your tests, you can di 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.1** +> **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.5** +> **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.0.0** +> **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.1.0** +> **vncrecorder.container.image = testcontainers/vnc-recorder:1.3.0** > Used by VNC recorder in Testcontainers' Selenium integration > **socat.container.image = alpine/socat** @@ -74,10 +73,10 @@ Some companies disallow the usage of Docker Hub, but you can override `*.image` ## Customizing Ryuk resource reaper -> **ryuk.container.image = testcontainers/ryuk:0.3.1** +> **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 = false** +> **ryuk.container.privileged = true** > In some environments ryuk must be started in privileged mode to work properly (--privileged flag) ### Disabling Ryuk @@ -91,14 +90,22 @@ but does not allow starting privileged containers, you can turn off the Ryuk con ## 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. +Testcontainers will attempt to detect the Docker environment and configure everything to work automatically. -However, sometimes a customization is required. For that, you can provide the following environment variables: +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) @@ -110,3 +117,18 @@ However, sometimes a customization is required. For that, you can provide the fo > **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 index d603981059a..a063e5329e4 100644 --- a/docs/features/container_logs.md +++ b/docs/features/container_logs.md @@ -40,19 +40,19 @@ By default both standard out and standard error will both be emitted at INFO lev Standard error may be emitted at ERROR level, if desired: ```java -Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(log).withSeparateOutputStreams() +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(log).withMdc("key", "value") +Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(LOGGER).withMdc("key", "value"); ``` or using an existing map of key-value pairs: ```java -Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(log).withMdc(map) +Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(LOGGER).withMdc(map); ``` ### Capturing container output as a String diff --git a/docs/features/creating_container.md b/docs/features/creating_container.md index 443e04f1a91..baaea5fb36d 100644 --- a/docs/features/creating_container.md +++ b/docs/features/creating_container.md @@ -34,14 +34,8 @@ It is suggested that developers treat `DockerImageName`s as you would any other A generic container rule can be used with any public docker image; for example: -[Creating a Redis container (JUnit 4)](../examples/junit4/generic/src/test/java/generic/ContainerCreationTest.java) inside_block:simple +[Creating a Redis container](../examples/junit5/redis/src/test/java/quickstart/RedisBackedCacheIntTest.java) inside_block:container -Further options may be specified: - - -[Creating a container with more options (JUnit 4)](../examples/junit4/generic/src/test/java/generic/ContainerCreationTest.java) inside_block:withOptions - - -These containers, as `@ClassRule`s, will be started before any tests in the class run, and will be destroyed after all +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/features/creating_images.md b/docs/features/creating_images.md index 0539642e159..8a239d342cc 100644 --- a/docs/features/creating_images.md +++ b/docs/features/creating_images.md @@ -13,9 +13,9 @@ Testcontainers will `docker build` a temporary container image, and will use it `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") @@ -51,7 +51,7 @@ new GenericContainer( new ImageFromDockerfile() .withDockerfileFromBuilder(builder -> builder - .from("alpine:3.2") + .from("alpine:3.17") .run("apk add --update nginx") .cmd("nginx", "-g", "daemon off;") .build())) diff --git a/docs/features/files.md b/docs/features/files.md index a74b1add134..d2c901678a6 100644 --- a/docs/features/files.md +++ b/docs/features/files.md @@ -1,11 +1,32 @@ # Files and volumes -## 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) -``` +## 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 index 37088473d72..0948e9fa2c7 100644 --- a/docs/features/image_name_substitution.md +++ b/docs/features/image_name_substitution.md @@ -53,7 +53,7 @@ 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.22` can be derived from the original Docker Hub image name (`mysql:8.0.22`) with a consistent prefix string: `registry.mycompany.com/mirror/` + 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: @@ -112,9 +112,10 @@ To do this, create or modify a file on the classpath named `testcontainers.prope For example: -```text tab="src/test/resources/testcontainers.properties" -image.substitutor=com.mycompany.testcontainers.ExampleImageNameSubstitutor -``` +=== "`src/test/resources/testcontainers.properties`" + ```text + image.substitutor=com.mycompany.testcontainers.ExampleImageNameSubstitutor + ``` Note that it is also possible to provide this same configuration property: @@ -124,13 +125,20 @@ Note that it is also possible to provide this same configuration property: 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 2021. + Overriding individual image names via configuration may be removed in the future. Consider this if: 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 index e7d8493483e..7ecf929da16 100644 --- a/docs/features/networking.md +++ b/docs/features/networking.md @@ -69,7 +69,8 @@ We need to tell Testcontainers to prepare to expose this port to containers: !!! warning - Note that the above command should be invoked _before_ containers are started, but _after_ the server on the host was started. + 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`. 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 index 3cbec97af0d..d760dfa93d0 100644 --- a/docs/features/startup_and_waits.md +++ b/docs/features/startup_and_waits.md @@ -116,10 +116,18 @@ Checks that the container is running and has been running for a defined minimum [Using minimum duration strategy](../examples/junit4/generic/src/test/java/org/testcontainers/containers/startupcheck/StartupCheckStrategyTest.java) inside_block:withMinimumDurationStrategy -### Other startup strategies +### 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/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 b53ebbf4db5..40304dd22ed 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,16 +1,26 @@ -# Testcontainers - -![Testcontainers logo](./logo.png) +# Testcontainers for Java + +

Not using Java? Here are other supported languages!

+
+ Java + Go + .NET + Node.js + Python + Rust + Haskell + Ruby +
-## About +## About Testcontainers for 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 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, 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. +* **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 @@ -31,18 +41,19 @@ Testcontainers is distributed as separate JARs with a common version number: For the core library, the latest Maven/Gradle dependency is as follows: -```groovy tab='Gradle' -testCompile "org.testcontainers:testcontainers:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - testcontainers - {{latest_version}} - test - -``` +=== "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). @@ -51,8 +62,8 @@ You can also [check the latest version available on Maven Central](https://searc 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`: - -```xml tab='Maven' +=== "Maven" +```xml @@ -65,37 +76,36 @@ Using Maven you can add the following to `dependencyManagement` section in your ``` -and then use dependencies without specifying a version: -```xml tab='Maven' - - org.testcontainers - mysql - test - -``` +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`: -```groovy tab='Gradle' -implementation platform('org.testcontainers:testcontainers-bom:{{latest_version}}') //import bom -testImplementation('org.testcontainers:mysql') //no version specified -``` +=== "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 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/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. + 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 -Testcontainers is on Github Sponsors! Please consider sponsoring us to sustain the project and help us improve it further. - - - A huge thank you to our sponsors: ### Bronze sponsors @@ -122,15 +132,48 @@ A huge thank you to our 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) -* [Pascal Zwick (@pas2al)](https://github.com/pas2al) * [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? @@ -142,14 +185,15 @@ A huge thank you to our sponsors: * [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 Puslar, Apache Bookeeper and Apache Heron. +* [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 new, reactive client implementations +* [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 @@ -158,17 +202,31 @@ A huge thank you to our sponsors: * [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 -* [Transferwise](https://transferwise.com/) - Integration testing for different RDBMS, kafka and micro services +* [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. ## License -See [LICENSE](https://raw.githubusercontent.com/testcontainers/testcontainers-java/master/LICENSE). +See [LICENSE](https://raw.githubusercontent.com/testcontainers/testcontainers-java/main/LICENSE). ## Attributions @@ -178,6 +236,6 @@ This project was initially inspired by a [gist](https://gist.github.com/mosheesh ## Copyright -Copyright (c) 2015-2020 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 index 7689772de92..30687dcd3bc 100644 --- a/docs/jitpack_dependencies.md +++ b/docs/jitpack_dependencies.md @@ -3,22 +3,51 @@ 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: -```xml - - com.github.testcontainers.testcontainers-java - --artifact name-- - -SNAPSHOT - -``` +=== "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 `093a3a4628`) can be used as a fixed version instead. The JitPack maven repository must also be declared, e.g.: +A specific git revision (such as `02782d9`) can be used as a fixed version instead: -```xml - - - jitpack.io - https://jitpack.io - - -``` +=== "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 index 3a8db707fc8..bac0c391a59 100644 --- a/docs/logo.svg +++ b/docs/logo.svg @@ -1,9 +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 index d15304afa81..9cc5e12499b 100644 --- a/docs/modules/databases/cassandra.md +++ b/docs/modules/databases/cassandra.md @@ -2,50 +2,52 @@ ## Usage example -This example connects to the Cassandra Cluster, creates a keyspaces and asserts that is has been created. +This example connects to the Cassandra cluster: -```java tab="JUnit 4 example" -public class SomeTest { +1. Define a container: + + [Container definition](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java) inside_block:container-definition + - @Rule - public CassandraContainer cassandra = new CassandraContainer(); +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 + - @Test - public void test(){ - Cluster cluster = cassandra.getCluster(); +## Using secure connection (TLS) - try(Session session = cluster.connect()) { +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: - session.execute("CREATE KEYSPACE IF NOT EXISTS test WITH replication = \n" + - "{'class':'SimpleStrategy','replication_factor':'1'};"); + +[SSL setup](../../../modules/cassandra/src/test/java/org/testcontainers/cassandra/CassandraContainerTest.java) inside_block:with-ssl-config + - List keyspaces = session.getCluster().getMetadata().getKeyspaces(); - List filteredKeyspaces = keyspaces - .stream() - .filter(km -> km.getName().equals("test")) - .collect(Collectors.toList()); - - assertEquals(1, filteredKeyspaces.size()); - } - } - -} -``` +!!! 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: -```groovy tab='Gradle' -testCompile "org.testcontainers:cassandra:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - cassandra - {{latest_version}} - test - -``` +=== "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 index 37f68c41e18..4e49c69c276 100644 --- a/docs/modules/databases/clickhouse.md +++ b/docs/modules/databases/clickhouse.md @@ -1,21 +1,39 @@ # 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: -```groovy tab='Gradle' -testCompile "org.testcontainers:clickhouse:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - clickhouse - {{latest_version}} - test - -``` +=== "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 index d9e6def4e48..add1b9cd1aa 100644 --- a/docs/modules/databases/cockroachdb.md +++ b/docs/modules/databases/cockroachdb.md @@ -1,26 +1,41 @@ # CockroachDB 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 [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: -```groovy tab='Gradle' -testCompile "org.testcontainers:cockroachdb:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - cockroachdb - {{latest_version}} - test - -``` +=== "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 index b71ee5de327..997347611e2 100644 --- a/docs/modules/databases/couchbase.md +++ b/docs/modules/databases/couchbase.md @@ -18,29 +18,25 @@ Running Couchbase as a stand-in in a test: [Container definition](../../../modules/couchbase/src/test/java/org/testcontainers/couchbase/CouchbaseContainerTest.java) inside_block:container_definition -3. create an environment & cluster: +3. create an cluster: [Cluster creation](../../../modules/couchbase/src/test/java/org/testcontainers/couchbase/CouchbaseContainerTest.java) inside_block:cluster_creation -4. authenticate: - - [Authentication](../../../modules/couchbase/src/test/java/org/testcontainers/couchbase/CouchbaseContainerTest.java) inside_block:auth - - ## Adding this module to your project dependencies Add the following dependency to your `pom.xml`/`build.gradle` file: -```groovy tab='Gradle' -testCompile "org.testcontainers:couchbase:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - couchbase - {{latest_version}} - test - -``` +=== "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 index bc25b343072..ce05f6ae308 100644 --- a/docs/modules/databases/db2.md +++ b/docs/modules/databases/db2.md @@ -1,50 +1,43 @@ # DB2 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. - -See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. +Testcontainers module for [DB2](https://www.ibm.com/docs/en/db2/11.5.x?topic=deployments-db2-community-edition-docker) ## Usage example -Running DB2 as a stand-in for in a test: - -```java -public class SomeTest { +You can start a DB2 container instance from any Java application by using: - @ClassRule - public Db2Container db2 = new Db2Container() - .acceptLicense(); - - @Test - public void someTestMethod() { - String url = db2.getJdbcUrl(); - - ... create a connection and run test as normal - } -``` + +[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). - - Please see the [`ibmcom/db2` image documentation](https://hub.docker.com/r/ibmcom/db2) 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: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: -```groovy tab='Gradle' -testCompile "org.testcontainers:db2:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - db2 - {{latest_version}} - test - -``` +=== "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/dynalite.md b/docs/modules/databases/dynalite.md deleted file mode 100644 index 2d5c7f7492d..00000000000 --- a/docs/modules/databases/dynalite.md +++ /dev/null @@ -1,46 +0,0 @@ -# Dynalite Module - -Testcontainers module for [Dynalite](https://github.com/mhart/dynalite). Dynalite is a clone of DynamoDB, enabling local testing. - -## Usage example - -Running Dynalite as a stand-in for DynamoDB in a test: - -```java -public class SomeTest { - - @Rule - public DynaliteContainer dynamoDB = new DynaliteContainer(); - - @Test - public void someTestMethod() { - // getClient() returns a preconfigured DynamoDB client that is connected to the - // dynalite container - final AmazonDynamoDB client = dynamoDB.getClient(); - - ... interact with client as if using DynamoDB normally -``` - -## Why Dynalite for DynamoDB testing? - -In part, because it's light and quick to run. Also, please see the [reasons given](https://github.com/mhart/dynalite#why-not-amazons-dynamodb-local) by the author of Dynalite and the [problems with Amazon's DynamoDB Local](https://github.com/mhart/dynalite#problems-with-amazons-dynamodb-local-updated-2016-04-19). - -## Adding this module to your project dependencies - -Add the following dependency to your `pom.xml`/`build.gradle` file: - -```groovy tab='Gradle' -testCompile "org.testcontainers:dynalite:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - dynalite - {{latest_version}} - test - -``` - -!!! hint - Adding this Testcontainers library JAR will not automatically add an AWS SDK JAR to your project. You should ensure that your project also has a suitable AWS SDK JAR as a dependency. diff --git a/docs/modules/databases/influxdb.md b/docs/modules/databases/influxdb.md index 740cf69982c..9644a5eb92b 100644 --- a/docs/modules/databases/influxdb.md +++ b/docs/modules/databases/influxdb.md @@ -1,35 +1,105 @@ # InfluxDB Module -Testcontainers module for InfluxData [InfluxDB](https://github.com/influxdata/influxdb). +Testcontainers module for InfluxData [InfluxDB](https://www.influxdata.com/products/influxdb/). -## Usage example +## Important note -Running influxDbContainer as a stand-in for InfluxDB in a test: +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). -```java -public class SomeTest { +## InfluxDB 2.x usage example - @Rule - public InfluxDBContainer influxDbContainer = new InfluxDBContainer(); - - @Test - public void someTestMethod() { - InfluxDB influxDB = influxDbContainer.getNewInfluxDB(); - ... -``` +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: -```groovy tab='Gradle' -testCompile "org.testcontainers:influxdb:{{latest_version}}" +=== "Gradle" + +```groovy +testImplementation "org.testcontainers:testcontainers-influxdb:{{latest_version}}" ``` -```xml tab='Maven' +=== "Maven" + +```xml + org.testcontainers - influxdb + testcontainers-influxdb {{latest_version}} test diff --git a/docs/modules/databases/jdbc.md b/docs/modules/databases/jdbc.md index 6853cf44c5c..72539aef44e 100644 --- a/docs/modules/databases/jdbc.md +++ b/docs/modules/databases/jdbc.md @@ -20,34 +20,91 @@ Insert `tc:` after `jdbc:` as follows. Note that the hostname, port and database !!! note We will use `///` (host-less URIs) from now on to emphasis the unimportance of the `host:port` pair. - From Testcontainers' perspective, `jdbc:mysql:5.7.22://localhost:3306/databasename` and `jdbc:mysql:5.7.22:///databasename` is the same URI. + 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 Testcontainers with a fixed version +#### Using ClickHouse -`jdbc:tc:mysql:5.6.23:///databasename` +`jdbc:tc:clickhouse:18.10.3:///databasename` -#### Using PostgreSQL +#### Using CockroachDB -`jdbc:tc:postgresql:9.6.8:///databasename` +`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 Presto +#### 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` -`jdbc:tc:presto:344://localhost/memory/default` ### 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:5.7.22:///databasename?TC_INITSCRIPT=somepath/init_mysql.sql` +`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. @@ -55,13 +112,13 @@ This is useful if you have a fixed script for setting up database schema, etc. 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:5.7.22:///databasename?TC_INITSCRIPT=file:src/main/resources/init_mysql.sql` +`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:5.7.22:///databasename?TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction` + `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 @@ -76,9 +133,9 @@ public class JDBCDriverTest { 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:5.7.22:///databasename?TC_DAEMON=true` + `jdbc:tc:mysql:8.0.36:///databasename?TC_DAEMON=true` -With this parameter database container will keep running even when there're no open connections. +With this parameter database container will keep running even when there's no open connections. ### Running container with tmpfs options @@ -115,5 +172,5 @@ Note that if you use `@Rule`, you will be given an isolated container for each t Examples/Tests: - * [MySQL](https://github.com/testcontainers/testcontainers-java/blob/master/modules/mysql/src/test/java/org/testcontainers/junit/mysql/SimpleMySQLTest.java) - * [PostgreSQL](https://github.com/testcontainers/testcontainers-java/blob/master/modules/postgresql/src/test/java/org/testcontainers/junit/postgresql/SimplePostgreSQLTest.java) + * [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 index 74a4c6e061e..d90f9381cb5 100644 --- a/docs/modules/databases/mariadb.md +++ b/docs/modules/databases/mariadb.md @@ -1,40 +1,46 @@ # MariaDB Module -See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. +Testcontainers module for [MariaDB](https://hub.docker.com/_/mariadb) ## Usage example -Running MariaDB as a stand-in for in a test: +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` -```java -public class SomeTest { +See [JDBC](./jdbc.md) for documentation. - @Rule - public MariaDBContainer mariaDB = new MariaDBContainer(); - - @Test - public void someTestMethod() { - String url = mariaDB.getJdbcUrl(); +## MariaDB `root` user password - ... create a connection and run test as normal -``` +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: -```groovy tab='Gradle' -testCompile "org.testcontainers:mariadb:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - mariadb - {{latest_version}} - test - -``` +=== "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 index fc2cb873f59..b861d83bb2c 100644 --- a/docs/modules/databases/mongodb.md +++ b/docs/modules/databases/mongodb.md @@ -1,20 +1,24 @@ # MongoDB 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. +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 +### Usage example The following example shows how to create a MongoDBContainer: -[Creating a MongoDB container](../../../modules/mongodb/src/test/java/org/testcontainers/containers/MongoDBContainerTest.java) inside_block:creatingMongoDBContainer +[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/containers/MongoDBContainerTest.java) inside_block:startingMongoDBContainer +[Starting a MongoDB container](../../../modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBContainerTest.java) inside_block:startingMongoDBContainer !!! note @@ -36,25 +40,57 @@ For instance, to initialize a single node replica set on fixed ports via Docker, 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: -```groovy tab='Gradle' -testCompile "org.testcontainers:mongodb:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - mongodb - {{latest_version}} - test - -``` +=== "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 + 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 index 29c5435be32..5c9d7b0a9fb 100644 --- a/docs/modules/databases/mssqlserver.md +++ b/docs/modules/databases/mssqlserver.md @@ -1,46 +1,45 @@ # MS SQL Server Module -See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. +Testcontainers module for [MS SQL Server](https://mcr.microsoft.com/en-us/artifact/mar/mssql/server/) ## Usage example -Running MS SQL Server as a stand-in for in a test: - -```java -public class SomeTest { +You can start a MS SQL Server container instance from any Java application by using: - @Rule - public MSSQLServerContainer mssqlserver = new MSSQLServerContainer() - .acceptLicense(); - - @Test - public void someTestMethod() { - String url = mssqlserver.getJdbcUrl(); - - ... create a connection and run test as normal -``` + +[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: -```groovy tab='Gradle' -testCompile "org.testcontainers:mssqlserver:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - mssqlserver - {{latest_version}} - test - -``` +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-mssqlserver:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-mssqlserver + {{latest_version}} + test + + ``` !!! hint @@ -48,10 +47,10 @@ testCompile "org.testcontainers:mssqlserver:{{latest_version}}" ## License -See [LICENSE](https://raw.githubusercontent.com/testcontainers/testcontainers-java/master/modules/mssqlserver/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/master/modules/mssqlserver/AUTHORS) for contributors. +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 index b32373f3363..1747989766f 100644 --- a/docs/modules/databases/mysql.md +++ b/docs/modules/databases/mysql.md @@ -1,34 +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:5.6://hostname/databasename?TC_MY_CNF=somepath/mysql_conf_override` + `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: -```groovy tab='Gradle' -testCompile "org.testcontainers:mysql:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - mysql - {{latest_version}} - test - -``` +=== "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 index aaa18d225c9..c7b06e99fe7 100644 --- a/docs/modules/databases/neo4j.md +++ b/docs/modules/databases/neo4j.md @@ -1,160 +1,109 @@ # Neo4j Module -This module helps running [Neo4j](https://neo4j.com/download/) using Testcontainers. +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 -Declare your Testcontainer as a `@ClassRule` or `@Rule` in a JUnit 4 test or as static or member attribute of a JUnit 5 test annotated with `@Container` as you would with other Testcontainers. -You can either use call `getHttpUrl()` or `getBoltUrl()` on the Neo4j container. -`getHttpUrl()` gives you the HTTP-address of the transactional HTTP endpoint while `getBoltUrl()` is meant to be used with one of the [official Bolt drivers](https://neo4j.com/developer/language-guides/). -On the JVM you would most likely use the [Java driver](https://github.com/neo4j/neo4j-java-driver). - -The following example uses the JUnit 5 extension `@Testcontainers` and demonstrates both the usage of the Java Driver and the REST endpoint: - -```java tab="JUnit 5 example" -@Testcontainers -public class ExampleTest { - - @Container - private static Neo4jContainer neo4jContainer = new Neo4jContainer() - .withAdminPassword(null); // 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, is(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(), is(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, is(expectedResponse)); - } - } -} -``` - -You are not limited to Unit tests and can of course use an instance of the Neo4j Testcontainer in vanilla Java code as well. +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: -```java -@Testcontainers -public class ExampleTest { + +[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: - @Container - Neo4jContainer neo4jContainer = new Neo4jContainer() - .withoutAuthentication(); -} -``` + +[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: +The container takes care of that, and you can configure the database with standard options like the following: -```java -@Testcontainers -public class ExampleTest { - - @Container - Neo4jContainer neo4jContainer = new Neo4jContainer() - .withNeo4jConfig("dbms.security.procedures.unrestricted", "apoc.*,algo.*"); -} -``` + +[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: -```java -@Testcontainers -public class ExampleTest { - - @Container - Neo4jContainer neo4jContainer = new Neo4jContainer() - .withPlugins(MountableFile.forClasspathResource("/apoc-3.5.0.1-all.jar")); -} -``` + +[Plugin jar](../../../modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java) inside_block:registerPluginsJar + Whole directories work as well: -```java -@Testcontainers -public class ExampleTest { + +[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 + - @Container - Neo4jContainer neo4jContainer = new Neo4jContainer() - .withPlugins(MountableFile.forClasspathResource("/my-plugins")); -} -``` ### 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: -```java -@Testcontainers -public class ExampleTest { + +[Copy database](../../../modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java) inside_block:copyDatabase + - @Container - Neo4jContainer neo4jContainer = new Neo4jContainer() - .withDatabase(MountableFile.forClasspathResource("/test-graph.db")); -} -``` +!!! 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: -```java -@Testcontainers -public class ExampleTest { - @ClassRule - public static Neo4jContainer neo4jContainer = new Neo4jContainer() - .withEnterpriseEdition(); -} -``` + +[Enterprise edition](../../../modules/neo4j/src/test/java/org/testcontainers/neo4j/Neo4jContainerTest.java) inside_block:enterpriseEdition + -This creates a Testcontainer based on the Docker image build with the Enterprise version of Neo4j. +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:3.5.0-enterprise` in one line. +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/). @@ -162,34 +111,33 @@ You'll find more information about licensing Neo4j here: [About Neo4j Licenses]( Add the following dependency to your `pom.xml`/`build.gradle` file: -```groovy tab='Gradle' -testCompile "org.testcontainers:neo4j:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - neo4j - {{latest_version}} - test - -``` - -!!! hint - Add the Neo4j Java driver if you plan to access the Testcontainer via Bolt: - - ```groovy tab='Gradle' - compile "org.neo4j.driver:neo4j-java-driver:1.7.1" +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-neo4j:{{latest_version}}" ``` - - ```xml tab='Maven' +=== "Maven" + ```xml - org.neo4j.driver - neo4j-java-driver - 1.7.1 + 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 index 86a9a5f938c..8d060ed65be 100644 --- a/docs/modules/databases/oraclexe.md +++ b/docs/modules/databases/oraclexe.md @@ -1,47 +1,40 @@ # Oracle-XE Module -See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. +Testcontainers module for [Oracle XE](https://hub.docker.com/r/gvenzl/oracle-xe) ## Usage example -Running Oracle XE as a stand-in for in a test: +You can start an Oracle-XE container instance from any Java application by using: -```java -public class SomeTest { + +[Container creation](../../../modules/oracle-xe/src/test/java/org/testcontainers/junit/oracle/SimpleOracleTest.java) inside_block:container + - @Rule - public OracleContainer oracle = new OracleContainer("name_of_your_oracle_xe_image"); - - @Test - public void someTestMethod() { - String url = oracle.getJdbcUrl(); +See [Database containers](./index.md) for documentation and usage that is common to all relational database container types. - ... create a connection and run test as normal -``` +### Testcontainers JDBC URL -## Specifying a docker image name via config +`jdbc:tc:oracle:21-slim-faststart:///databasename` -If you do not pass an image name to the `OracleContainer` constructor, a suitable image name should be placed in -configuration instead. -To do this, please place a file on the classpath named `testcontainers.properties`, -containing `oracle.container.image=IMAGE`, where IMAGE is a suitable image name and tag. +See [JDBC](./jdbc.md) for documentation. ## Adding this module to your project dependencies Add the following dependency to your `pom.xml`/`build.gradle` file: -```groovy tab='Gradle' -testCompile "org.testcontainers:oracle-xe:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - oracle-xe - {{latest_version}} - test - -``` +=== "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 index 80ed96f9548..f3be7e2bbdb 100644 --- a/docs/modules/databases/orientdb.md +++ b/docs/modules/databases/orientdb.md @@ -1,77 +1,49 @@ # OrientDB 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. - - -This module helps running [OrientDB](https://orientdb.org/download) using Testcontainers. - -Note that it's based on the [official Docker image](https://hub.docker.com/_/orientdb/) provided by OrientDB. +Testcontainers module for [OrientDB](https://hub.docker.com/_/orientdb/) ## Usage example -Declare your Testcontainer as a `@ClassRule` or `@Rule` in a JUnit 4 test or as static or member attribute of a JUnit 5 test annotated with `@Container` as you would with other Testcontainers. -You can call `getDbUrl()` OrientDB container and build the `ODatabaseSession` by your own, but a more useful `getSession()` method is provided. -On the JVM you would most likely use the [Java driver](https://github.com/). - -The following example uses the JUnit 5 extension `@Testcontainers` and demonstrates both the usage of the Java Client: - -```java tab="JUnit 5 example" -@Testcontainers -public class ExampleTest { - - @Container - private static OrientDBContainer container = new OrientDBContainer(); - - @Test - void testDbCreation() { - - final ODatabaseSession session = container.getSession(); - - 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); - } - -} -``` - -You are not limited to Unit tests and can of course use an instance of the OrientDB Testcontainer in vanilla Java code as well. +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: -```groovy tab='Gradle' -testCompile "org.testcontainers:orientdb:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - orientdb - {{latest_version}} - test - -``` +=== "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: - ```groovy tab='Gradle' - compile "com.orientechnologies:orientdb-client:3.0.24" - ``` + === "Gradle" + ```groovy + compile "com.orientechnologies:orientdb-client:3.0.24" + ``` - ```xml tab='Maven' - - 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 index 07831766d1b..f27752b8354 100644 --- a/docs/modules/databases/postgres.md +++ b/docs/modules/databases/postgres.md @@ -1,23 +1,65 @@ # 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: -```groovy tab='Gradle' -testCompile "org.testcontainers:postgresql:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - postgresql - {{latest_version}} - test - -``` +=== "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 index 52776d06daf..4e9a6e7c95d 100644 --- a/docs/modules/databases/presto.md +++ b/docs/modules/databases/presto.md @@ -1,7 +1,7 @@ # Presto 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. + This module is deprecated, use Trino module. See [Database containers](./index.md) for documentation and usage that is common to all database container types. @@ -67,18 +67,19 @@ public class SomeTest { Add the following dependency to your `pom.xml`/`build.gradle` file: -```groovy tab='Gradle' -testCompile "org.testcontainers:presto:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - presto - {{latest_version}} - test - -``` +=== "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. 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 index 6ba3f401a08..91580811887 100644 --- a/docs/modules/databases/r2dbc.md +++ b/docs/modules/databases/r2dbc.md @@ -12,7 +12,7 @@ As long as you have Testcontainers and the appropriate R2DBC driver on your clas The started container will be terminated when the `ConnectionFactory` is closed. !!! warning - Both the database module (e.g. `org.testcontainers:mysql`) **and** `org.testcontainers:r2dbc` need to be on your application's classpath at runtime. + 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` @@ -22,7 +22,7 @@ The started container will be terminated when the `ConnectionFactory` is closed. **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=5.7.22` +`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. @@ -33,13 +33,17 @@ So that the URL becomes: ### 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=5.6.23` +`r2dbc:tc:mysql:///databasename?TC_IMAGE_TAG=8.0.36` #### Using MariaDB -`r2dbc:tc:mariadb:///databasename?TC_IMAGE_TAG=10.3.6` +`r2dbc:tc:mariadb:///databasename?TC_IMAGE_TAG=10.3.39` #### Using PostgreSQL @@ -49,6 +53,10 @@ So that the URL becomes: `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: 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 index fa584d7f631..3d3d7a510c2 100644 --- a/docs/modules/docker_compose.md +++ b/docs/modules/docker_compose.md @@ -2,125 +2,121 @@ ## Benefits -Similar to generic containers support, it's also possible to run a bespoke set of services -specified in a `docker-compose.yml` file. +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 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. +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. -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. +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 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); -``` +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 + -In this example, `compose-test.yml` should have content such as: +!!! note + Make sure the service names use a `-` rather than `_` as separator. + +In this example, Docker Compose file should have content such as: ```yaml -redis: - image: redis -elasticsearch: - image: elasticsearch +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; this would inhibit reuse/inclusion of the -file in other contexts. +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. -Instead, Testcontainers will spin up a small 'ambassador' container, which will proxy -between the Compose-managed containers and ports that are accessible to your tests. This is done using a separate, minimal -container that runs socat as a TCP proxy. +## ComposeContainer vs DockerComposeContainer -## Accessing a container from tests +So far, we discussed `ComposeContainer`, which supports docker compose [version 2](https://www.docker.com/blog/announcing-compose-v2-general-availability/). -The rule provides methods for discovering how your tests can interact with the containers: +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) -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); -``` +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 + -## Startup timeout +## 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` so you can specify a timeout strategy per container. +There are overloaded `withExposedService` methods that take a `WaitStrategy` +where we can specify a timeout strategy per container. -### Waiting for startup examples +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. -Waiting for exposed port to start listening: -```java -@ClassRule -public static DockerComposeContainer environment = - new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) - .withExposedService("redis_1", REDIS_PORT, - Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(30))); -``` +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 + -Wait for arbitrary status codes on an HTTPS endpoint: -```java -@ClassRule -public static DockerComposeContainer environment = - new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) - .withExposedService("elasticsearch_1", ELASTICSEARCH_PORT, - Wait.forHttp("/all") - .forStatusCode(200) - .forStatusCode(401) - .usingTls()); -``` +Needless to say, we can define different strategies for each service in our Docker Compose setup. -Separate wait strategies for each container: -```java -@ClassRule -public static DockerComposeContainer environment = - new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) - .withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort()) - .withExposedService("elasticsearch_1", ELASTICSEARCH_PORT, - Wait.forHttp("/all") - .forStatusCode(200) - .forStatusCode(401) - .usingTls()); -``` +For example, our Redis container can wait for a successful redis-cli command, +while our db service waits for a specific log message: -Alternatively, you can use `waitingFor(serviceName, waitStrategy)`, -for example if you need to wait on a log message from a service, but don't need to expose a port. + +[Wait for a custom command and a log message](../../core/src/test/java/org/testcontainers/junit/ComposeContainerWithWaitStrategiesTest.java) inside_block:composeContainerWithCombinedWaitStrategies + -```java -@ClassRule -public static DockerComposeContainer environment = - new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) - .withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort()) - .waitingFor("db_1", Wait.forLogMessage("started", 1)); -``` -## 'Local compose' mode -You 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. -```java -public static DockerComposeContainer environment = - new DockerComposeContainer(new File("src/test/resources/compose-test.yml")) - .withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort()) - .waitingFor("db_1", Wait.forLogMessage("started", 1)) - .withLocalCompose(true); -``` +## 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's needs to be made aware of Docker settings for private repositories. +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: @@ -177,16 +173,17 @@ There are 3 ways to specify location of the `config.json` for Docker Compose: Add the following dependency to your `pom.xml`/`build.gradle` file: -```groovy tab='Gradle' -testCompile "org.testcontainers:testcontainers:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - testcontainers - {{latest_version}} - test - -``` +=== "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 index cd47c9c4ed3..5817a21de02 100644 --- a/docs/modules/elasticsearch.md +++ b/docs/modules/elasticsearch.md @@ -10,7 +10,9 @@ Note that it's based on the [official Docker image](https://www.elastic.co/guide 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:httpClientContainer +[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 @@ -28,28 +30,48 @@ You can turn on security by providing a password: [HttpClient](../../modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java) inside_block:httpClientSecuredContainer -## Choose your Elasticsearch license +## Kibana container -If you prefer to start a Docker image with the pure OSS version (which means with no security in older versions or -other new and advanced features), you can use this instead: +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`: -[Elasticsearch OSS](../../modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java) inside_block:ossContainer +[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: -```groovy tab='Gradle' -testCompile "org.testcontainers:elasticsearch:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - elasticsearch - {{latest_version}} - test - -``` +=== "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 index 33d50ee634f..9660461d5a0 100644 --- a/docs/modules/gcloud.md +++ b/docs/modules/gcloud.md @@ -5,36 +5,49 @@ Testcontainers module for the Google Cloud Platform's [Cloud SDK](https://cloud.google.com/sdk/). -Currently, the module supports `Bigtable`, `Datastore`, `Firestore`, `Spanner`, and `Pub/Sub` emulators. In order to use it, you should use the following classes: +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 -|- -BigtableEmulatorContainer | [gcr.io/google.com/cloudsdktool/cloud-sdk:emulators](https://gcr.io/google.com/cloudsdktool/cloud-sdk) -DatastoreEmulatorContainer | [gcr.io/google.com/cloudsdktool/cloud-sdk:emulators](https://gcr.io/google.com/cloudsdktool/cloud-sdk) -FirestoreEmulatorContainer | [gcr.io/google.com/cloudsdktool/cloud-sdk:emulators](https://gcr.io/google.com/cloudsdktool/cloud-sdk) +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/cloud-sdk:emulators](https://gcr.io/google.com/cloudsdktool/cloud-sdk) +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/containers/BigtableEmulatorContainerTest.java) inside_block:emulatorContainer +[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/containers/BigtableEmulatorContainerTest.java) inside_block:createTable +[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/containers/BigtableEmulatorContainerTest.java) inside_block:testWithEmulatorContainer +[Testing with a Bigtable Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/gcloud/BigtableEmulatorContainerTest.java) inside_block:testWithEmulatorContainer ### Datastore @@ -42,18 +55,18 @@ Test against the Emulator: Start Datastore Emulator during a test: -[Starting a Datastore Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/containers/DatastoreEmulatorContainerTest.java) inside_block:creatingDatastoreEmulatorContainer +[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/containers/DatastoreEmulatorContainerTest.java) inside_block:startingDatastoreEmulatorContainer +[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/master/modules/gcloud/src/test/java/org/testcontainers/containers/DatastoreEmulatorContainerTest.java) + * [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 @@ -61,18 +74,18 @@ See more examples: Start Firestore Emulator during a test: -[Starting a Firestore Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/containers/FirestoreEmulatorContainerTest.java) inside_block:emulatorContainer +[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/containers/FirestoreEmulatorContainerTest.java) inside_block:testWithEmulatorContainer +[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/master/modules/gcloud/src/test/java/org/testcontainers/containers/FirestoreEmulatorContainerTest.java) + * [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 @@ -80,30 +93,30 @@ See more examples: Start Spanner Emulator during a test: -[Starting a Spanner Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/containers/SpannerEmulatorContainerTest.java) inside_block:emulatorContainer +[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/containers/SpannerEmulatorContainerTest.java) inside_block:createInstance +[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/containers/SpannerEmulatorContainerTest.java) inside_block:createDatabase +[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 Firestore Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/containers/SpannerEmulatorContainerTest.java) inside_block:testWithEmulatorContainer +[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/master/modules/gcloud/src/test/java/org/testcontainers/containers/SpannerEmulatorContainerTest.java) + * [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 @@ -111,45 +124,46 @@ See more examples: Start Pub/Sub Emulator during a test: -[Starting a Pub/Sub Emulator container](../../modules/gcloud/src/test/java/org/testcontainers/containers/PubSubEmulatorContainerTest.java) inside_block:emulatorContainer +[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/containers/PubSubEmulatorContainerTest.java) inside_block:createTopic +[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/containers/PubSubEmulatorContainerTest.java) inside_block:createSubscription +[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/containers/PubSubEmulatorContainerTest.java) inside_block:testWithEmulatorContainer +[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/master/modules/gcloud/src/test/java/org/testcontainers/containers/PubSubEmulatorContainerTest.java) + * [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: -```groovy tab='Gradle' -testCompile "org.testcontainers:gcloud:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - gcloud - {{latest_version}} - test - -``` +=== "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 index b74d4ef49bd..cb2906265d0 100644 --- a/docs/modules/kafka.md +++ b/docs/modules/kafka.md @@ -1,64 +1,99 @@ -# Kafka Containers +# Kafka Module Testcontainers can be used to automatically instantiate and manage [Apache Kafka](https://kafka.apache.org) containers. -More precisely Testcontainers uses the official Docker images for [Confluent OSS Platform](https://hub.docker.com/r/confluentinc/cp-kafka/) + +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. But see [below](#zookeeper) +* No need to manage external Zookeeper installation, required by Kafka. ## Example -The following field in your JUnit test class will prepare a container running Kafka: +### Using org.testcontainers.kafka.KafkaContainer + +Create a `KafkaContainer` to use it in your tests: + -[JUnit Rule](../../modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java) inside_block:junitRule +[Creating a KafkaContainer](../../modules/kafka/src/test/java/org/testcontainers/kafka/KafkaContainerTest.java) inside_block:constructorWithVersion -The correspondence between Confluent Platform versions and Kafka versions can be seen [in Confluent documentation](https://docs.confluent.io/current/installation/versions-interoperability.html#cp-and-apache-kafka-compatibility) - 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 external Zookeeper +### 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) -If for some reason you want to use an externally running Zookeeper, then just pass its location during construction: -[External Zookeeper](../../modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java) inside_block:withExternalZookeeper +[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/). -## Multi-container usage + +[Register additional listener](../../modules/kafka/src/test/java/org/testcontainers/kafka/KafkaContainerTest.java) inside_block:registerListener + -If your test needs to run some other Docker container which needs access to Kafka, do the following: +Container defined in the same network: -* Run your other container on the same network as Kafka container, e.g.: -[Network](../../modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java) inside_block:withKafkaNetwork +[Create kcat container](../../modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java) inside_block:createKCatContainer -* Use `kafka.getNetworkAliases().get(0)+":9092"` as bootstrap server location. -Or just give your Kafka container a network alias of your liking. -You will need to explicitly create a network and set it on the Kafka container as well as on your other containers that need to communicate with Kafka. +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: -```groovy tab='Gradle' -testCompile "org.testcontainers:kafka:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - kafka - {{latest_version}} - test - -``` +=== "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 index 41fd2a50a52..ca51979f5a0 100644 --- a/docs/modules/localstack.md +++ b/docs/modules/localstack.md @@ -1,80 +1,44 @@ -# Localstack Module +# LocalStack Module -Testcontainers module for the Atlassian's [LocalStack](https://github.com/localstack/localstack), 'a fully functional local AWS cloud stack'. +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 -Running LocalStack as a stand-in for AWS S3 during a test: +You can start a LocalStack container instance from any Java application by using: -```java -DockerImageName localstackImage = DockerImageName.parse("localstack/localstack:0.11.3"); + +[Container creation](../../modules/localstack/src/test/java/org/testcontainers/localstack/LocalStackContainerTest.java) inside_block:container + -@Rule -public LocalStackContainer localstack = new LocalStackContainer(localstackImage) - .withServices(S3); - -@Test -public void someTestMethod() { - // AWS SDK v1 - AmazonS3 s3 = AmazonS3ClientBuilder - .standard() - .withEndpointConfiguration(localstack.getEndpointConfiguration(S3)) - .withCredentials(localstack.getDefaultCredentialsProvider()) - .build(); - - s3.createBucket("foo"); - s3.putObject("foo", "bar", "baz"); - - // AWS SDK v2 - 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(); - - s3.createBucket(b -> b.bucket("foo")); - s3.putObject(b -> b.bucket("foo").key("bar"), RequestBody.fromBytes("baz".getBytes())); -``` - -Environment variables listed in [Localstack's README](https://github.com/localstack/localstack#configurations) may be used to customize Localstack's configuration. +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. -## `HOSTNAME_EXTERNAL` and hostname-sensitive services - -Some Localstack APIs, such as SQS, require the container to be aware of the hostname that it is accessible on - for example, for construction of queue URLs in responses. +!!! note + Starting March 23, 2026, `localstack/localstack` requires authentication via a `LOCALSTACK_AUTH_TOKEN` environment variable. Without it, the container will fail to start. -Testcontainers will inform Localstack of the best hostname automatically, using the `HOSTNAME_EXTERNAL` environment variable: + 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. -* when running the Localstack container directly without a custom network defined, it is expected that all calls to the container will be from the test host. As such, the container address will be used (typically localhost or the address where the Docker daemon is running). +## Creating a client using AWS SDK - - [Localstack container running without a custom network](../../modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackContainerTest.java) inside_block:without_network - - -* when running the Localstack container [with a custom network defined](/features/networking/#advanced-networking), it is expected that all calls to the container will be **from other containers on that network**. `HOSTNAME_EXTERNAL` will be set to the *last* network alias that has been configured for the Localstack container. - - - [Localstack container running without a custom network](../../modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackContainerTest.java) inside_block:with_network - - -* Other usage scenarios, such as where the Localstack container is used from both the test host and containers on a custom network are not automatically supported. If you have this use case, you should set `HOSTNAME_EXTERNAL` manually. + +[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: -```groovy tab='Gradle' -testCompile "org.testcontainers:localstack:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - localstack - {{latest_version}} - test - -``` +=== "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 index ead9d123915..e6a0ee91ece 100644 --- a/docs/modules/mockserver.md +++ b/docs/modules/mockserver.md @@ -7,31 +7,32 @@ Mock Server can be used to mock HTTP services by matching requests against user- The following example shows how to start Mockserver. -[Creating a MockServer container](../../modules/mockserver/src/test/java/org/testcontainers/containers/MockServerContainerRuleTest.java) inside_block:creatingProxy +[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/containers/MockServerContainerRuleTest.java) inside_block:testSimpleExpectation +[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: -```groovy tab='Gradle' -testCompile "org.testcontainers:mockserver:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - mockserver - {{latest_version}} - test - -``` +=== "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 index d195b5451a2..6fcdbdaf13f 100644 --- a/docs/modules/nginx.md +++ b/docs/modules/nginx.md @@ -7,34 +7,35 @@ Nginx is a web server, reverse proxy and mail proxy and http cache. The following example shows how to start Nginx. -[Creating a Nginx container](../../modules/nginx/src/test/java/org/testcontainers/junit/SimpleNginxTest.java) inside_block:creatingContainer +[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/junit/SimpleNginxTest.java) inside_block:addCustomContent +[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/junit/SimpleNginxTest.java) inside_block:getFromNginxServer +[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: -```groovy tab='Gradle' -testCompile "org.testcontainers:nginx:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - nginx - {{latest_version}} - test - -``` +=== "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 index 8e6441586f8..5bd33b0bc4c 100644 --- a/docs/modules/pulsar.md +++ b/docs/modules/pulsar.md @@ -1,18 +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: -```groovy tab='Gradle' -testCompile "org.testcontainers:pulsar:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - pulsar - {{latest_version}} - test - -``` +=== "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 index 92275ad765b..5381e3cd910 100644 --- a/docs/modules/rabbitmq.md +++ b/docs/modules/rabbitmq.md @@ -1,21 +1,19 @@ # RabbitMQ 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. - ## Adding this module to your project dependencies Add the following dependency to your `pom.xml`/`build.gradle` file: -```groovy tab='Gradle' -testCompile "org.testcontainers:rabbitmq:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - rabbitmq - {{latest_version}} - test - -``` +=== "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 index 4776ffa608b..957615fcbf1 100644 --- a/docs/modules/solr.md +++ b/docs/modules/solr.md @@ -1,10 +1,6 @@ # Solr Container -!!! 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. - - -This module helps running [solr](https://lucene.apache.org/solr/) using Testcontainers. +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/). @@ -13,22 +9,23 @@ Note that it's based on the [official Docker image](https://hub.docker.com/_/sol You can start a solr container instance from any Java application by using: -[Using a Solr container](../../modules/solr/src/test/java/org/testcontainers/containers/SolrContainerTest.java) inside_block:solrContainerUsage +[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: -```groovy tab='Gradle' -testCompile "org.testcontainers:solr:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - solr - {{latest_version}} - test - -``` +=== "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 index 8ffb910d6c1..40f792911bb 100644 --- a/docs/modules/toxiproxy.md +++ b/docs/modules/toxiproxy.md @@ -17,7 +17,7 @@ A Toxiproxy container can be placed in between test code and a container, or in 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/containers/ToxiproxyTest.java) inside_block:creatingProxy +[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. @@ -26,22 +26,19 @@ 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/containers/ToxiproxyTest.java) inside_block:obtainProxyObject +[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 for connections from the host machine](../../modules/toxiproxy/src/test/java/org/testcontainers/containers/ToxiproxyTest.java) inside_block:obtainProxiedHostAndPortForHostMachine +[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. -To establish a connection from a different container on the same network to the target container via Toxiproxy, we use **Toxiproxy's** network alias and original port: - - -[Obtaining proxied host and port for connections from a different container](../../modules/toxiproxy/src/test/java/org/testcontainers/containers/ToxiproxyTest.java) inside_block:obtainProxiedHostAndPortForDifferentContainer - +!!! note + Currently, `ToxiProxyContainer` will reserve 31 ports, starting at 8666. Other containers should connect to this proxied host and port. @@ -59,34 +56,33 @@ Please see the [Toxiproxy documentation](https://github.com/Shopify/toxiproxy#to 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/containers/ToxiproxyTest.java) inside_block:addingLatency +[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/containers/ToxiproxyTest.java) inside_block:disableProxy +[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: -```groovy tab='Gradle' -testCompile "org.testcontainers:toxiproxy:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - toxiproxy - {{latest_version}} - test - -``` +=== "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). - -[toxiproxy-java](https://github.com/trekawek/toxiproxy-java) is used to help control failure conditions. 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 index 1f58c0ab836..161efa8ccb0 100644 --- a/docs/modules/vault.md +++ b/docs/modules/vault.md @@ -4,63 +4,49 @@ Testcontainers module for [Vault](https://github.com/hashicorp/vault). Vault is ## Usage example -Running Vault in your Junit tests is easily done with an @Rule or @ClassRule such as the following: - -```java -public class SomeTest { - - @ClassRule - public static VaultContainer vaultContainer = new VaultContainer<>() - .withVaultToken("my-root-token") - .withVaultPort(8200) - .withSecretInVault("secret/testing", "top_secret=password1","db_password=dbpassword1"); - - @Test - public void someTestMethod() { - //interact with Vault via the container's host, port and Vault token. - - //There are many integration clients for Vault so let's just define a general one here: - VaultClient client = new VaultClient( - vaultContainer.getHost(), - vaultContainer.getMappedPort(8200), - "my-root-token"); - - List secrets = client.readSecret("secret/testing"); - - } -``` - -## Why Vault in Junit tests? - -With the increasing popularity of Vault and secret management, applications are now needing to source secrets from Vault. -This can prove challenging in the development phase without a running Vault instance readily on hand. This library -aims to solve your apps integration testing with Vault. You can also use it to -test how your application behaves with Vault by writing different test scenarios in Junit. +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: -```groovy tab='Gradle' -testCompile "org.testcontainers:vault:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - vault - {{latest_version}} - test - -``` +=== "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/master/modules/vault/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/master/modules/vault/AUTHORS) for contributors. +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 index 41b7fb6bb23..8101f489e3a 100644 --- a/docs/modules/webdriver_containers.md +++ b/docs/modules/webdriver_containers.md @@ -5,7 +5,7 @@ from SeleniumHQ's [docker-selenium](https://github.com/SeleniumHQ/docker-seleniu ## Benefits -* Fully compatible with Selenium 2/Webdriver tests, by providing a `RemoteWebDriver` instance +* 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 @@ -23,23 +23,22 @@ every test. The following field in your JUnit UI test class will prepare a container running Chrome: -[Chrome](../../modules/selenium/src/test/java/org/testcontainers/junit/ChromeWebDriverContainerTest.java) inside_block:junitRule +[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/junit/LocalServerWebDriverContainerTest.java) inside_block:getWebDriver +[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 replace any references to `localhost` with an IP address that the -Docker container can reach. Use the `getTestHostIpAddress()` method, e.g.: +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/junit/LocalServerWebDriverContainerTest.java) inside_block:getPage +[Open Web Page](../../modules/selenium/src/test/java/org/testcontainers/selenium/LocalServerWebDriverContainerTest.java) inside_block:getPage @@ -47,10 +46,11 @@ Docker container can reach. Use the `getTestHostIpAddress()` method, e.g.: ### Other browsers -At the moment, Chrome and Firefox are supported. To switch, simply change the first parameter to the rule constructor: +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/junit/ChromeWebDriverContainerTest.java) inside_block:junitRule -[Firefox](../../modules/selenium/src/test/java/org/testcontainers/junit/FirefoxWebDriverContainerTest.java) inside_block:junitRule +[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 @@ -59,8 +59,8 @@ By default, no videos will be recorded. However, you can instruct Testcontainers just for failing tests. -[Record all Tests](../../modules/selenium/src/test/java/org/testcontainers/junit/ChromeRecordingWebDriverContainerTest.java) inside_block:recordAll -[Record failing Tests](../../modules/selenium/src/test/java/org/testcontainers/junit/ChromeRecordingWebDriverContainerTest.java) inside_block:recordFailing +[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. @@ -68,13 +68,13 @@ Note that the second parameter of `withRecordingMode` should be a directory wher 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/junit/ChromeRecordingWebDriverContainerTest.java) inside_block:recordMp4 -[Video Format in FLV](../../modules/selenium/src/test/java/org/testcontainers/junit/ChromeRecordingWebDriverContainerTest.java) inside_block:recordFlv +[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/junit/ChromeRecordingWebDriverContainerTest.java) inside_block:withRecordingFileFactory +[CustomRecordingFileFactory](../../modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java) inside_block:withRecordingFileFactory @@ -82,38 +82,41 @@ Note the factory must implement `org.testcontainers.containers.RecordingFileFact ## 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). +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: -```groovy tab='Gradle' -testCompile "org.testcontainers:selenium:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - selenium - {{latest_version}} - test - -``` +=== "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: - ```groovy tab='Gradle' - compile "org.seleniumhq.selenium:selenium-remote-driver:3.141.59" - ``` + === "Gradle" + ```groovy + compile "org.seleniumhq.selenium:selenium-remote-driver:3.141.59" + ``` - ```xml tab='Maven' - - 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 index a3fecb1a14d..57946c1fb74 100644 --- a/docs/quickstart/junit_4_quickstart.md +++ b/docs/quickstart/junit_4_quickstart.md @@ -21,18 +21,19 @@ Let's start from here, and see how to improve the test with Testcontainers: First, add Testcontainers as a dependency as follows: -```groovy tab='Gradle' -testCompile "org.testcontainers:testcontainers:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - testcontainers - {{latest_version}} - test - -``` +=== "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 diff --git a/docs/quickstart/junit_5_quickstart.md b/docs/quickstart/junit_5_quickstart.md index efd52b6c523..effd17dd2d9 100644 --- a/docs/quickstart/junit_5_quickstart.md +++ b/docs/quickstart/junit_5_quickstart.md @@ -21,47 +21,33 @@ Let's start from here, and see how to improve the test with Testcontainers: First, add Testcontainers as a dependency as follows: -```groovy tab='Gradle' -def junitJupiterVersion = '5.4.2' -testCompile "org.junit.jupiter:junit-jupiter-api:$junitJupiterVersion" -testCompile "org.junit.jupiter:junit-jupiter-params:$junitJupiterVersion" -testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitJupiterVersion" -testCompile "org.testcontainers:testcontainers:{{latest_version}}" -testCompile "org.testcontainers:junit-jupiter:{{latest_version}}" -``` - -```xml tab='Maven' - - org.junit.jupiter - junit-jupiter-api - 5.4.2 - test - - - org.junit.jupiter - junit-jupiter-params - 5.4.2 - test - - - org.junit.jupiter - junit-jupiter-engine - 5.4.2 - test - - - org.testcontainers - testcontainers - {{latest_version}} - test - - - org.testcontainers - junit-jupiter - {{latest_version}} - test - -``` +=== "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 @@ -99,7 +85,16 @@ We can do this in our test `setUp` method, to set up our component under test: not work on your current or future CI environment. As such, **avoid hard-coding** the address, and use `getHost()` instead. -## 4. Run the tests! +## 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! diff --git a/docs/quickstart/spock_quickstart.md b/docs/quickstart/spock_quickstart.md index 902f1a78c09..c9c78bea5ea 100644 --- a/docs/quickstart/spock_quickstart.md +++ b/docs/quickstart/spock_quickstart.md @@ -21,27 +21,29 @@ Let's start from here, and see how to improve the test with Testcontainers: First, add Testcontainers as a dependency as follows: -```groovy tab='Gradle' -testCompile "org.testcontainers:spock:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - spock - {{latest_version}} - test - -``` +=== "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: -```groovy tab='Spock Testcontainers annotation' -@org.testcontainers.spock.Testcontainers -class RedisBackedCacheIntTest extends Specification { -``` +=== "Spock Testcontainers annotation" + ```groovy + @org.testcontainers.spock.Testcontainers + class RedisBackedCacheIntTest extends Specification { + ``` And add the following field to the body of our test class: 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/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/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/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/circle_ci.md b/docs/supported_docker_environment/continuous_integration/circle_ci.md index 64e7ea131f2..936192cc799 100644 --- a/docs/supported_docker_environment/continuous_integration/circle_ci.md +++ b/docs/supported_docker_environment/continuous_integration/circle_ci.md @@ -1,19 +1,18 @@ -# CircleCI 2.0 +# 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/2.0/executor-types/) for more info ). +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: +Here is a sample CircleCI configuration that does a checkout of a project and runs Maven: ```yml -# Check https://circleci.com/docs/2.0/language-java/ for more details -# -version: 2 -machine: true 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/supported_docker_environment/continuous_integration/dind_patterns.md b/docs/supported_docker_environment/continuous_integration/dind_patterns.md index e742c047afa..2ea0627216e 100644 --- a/docs/supported_docker_environment/continuous_integration/dind_patterns.md +++ b/docs/supported_docker_environment/continuous_integration/dind_patterns.md @@ -27,10 +27,17 @@ $ docker run -it --rm -v $PWD:$PWD -w $PWD -v /var/run/docker.sock:/var/run/dock ``` 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 + +!!! 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 diff --git a/docs/supported_docker_environment/continuous_integration/gitlab_ci.md b/docs/supported_docker_environment/continuous_integration/gitlab_ci.md index 0cb5017bb87..ccc73f58078 100644 --- a/docs/supported_docker_environment/continuous_integration/gitlab_ci.md +++ b/docs/supported_docker_environment/continuous_integration/gitlab_ci.md @@ -1,18 +1,55 @@ # 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-executor) (`docker:dind`) and set the `DOCKER_HOST` variable to `tcp://docker:2375`. +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: - - docker:dind + - name: docker:dind + # explicitly disable tls to avoid docker startup interruption + command: ["--tls=false"] variables: - # Instruct Testcontainers to use the daemon of DinD. + # 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 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 index 860c07f675d..f4ea31abaea 100644 --- a/docs/supported_docker_environment/continuous_integration/travis.md +++ b/docs/supported_docker_environment/continuous_integration/travis.md @@ -1,3 +1,15 @@ # Travis -> TODO +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/index.md b/docs/supported_docker_environment/index.md index 04cc0c9ee2b..0bdac109245 100644 --- a/docs/supported_docker_environment/index.md +++ b/docs/supported_docker_environment/index.md @@ -1,18 +1,91 @@ -# General Docker requirements +# General Container runtime requirements ## Overview -| 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 - CircleCI (LXC driver) | Docker v1.9.1 | The `exec` feature is not compatible with CircleCI. See CircleCI configuration [example](./continuous_integration/circle_ci.md) | -| Linux - within a Docker container | Docker v1.12 | See [Running inside Docker](continuous_integration/dind_patterns.md) for Docker-in-Docker and Docker wormhole patterns | -| 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 | | *Support is best-efforts at present.* Only Linux Containers (LCOW) are supported at the moment. See [Windows Support](windows.md) | -| Windows - Windows Subsystem for Linux (WSL) | Docker v17.06 | *Support is best-efforts at present.* Only Linux Containers (LCOW) are supported at the moment. See [Windows Support](windows.md). | +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 @@ -28,3 +101,12 @@ Testcontainers will try to connect to a Docker daemon using the following strate * `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 index 5c7abcd211c..6bbe0584434 100644 --- a/docs/supported_docker_environment/logging_config.md +++ b/docs/supported_docker_environment/logging_config.md @@ -17,6 +17,17 @@ should be included in your classpath to show a reasonable level of log output: + + + ``` + +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/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_5.md b/docs/test_framework_integration/junit_5.md index 7f3689c0301..aed2d515af9 100644 --- a/docs/test_framework_integration/junit_5.md +++ b/docs/test_framework_integration/junit_5.md @@ -1,7 +1,6 @@ # Jupiter / JUnit 5 -While Testcontainers is tightly coupled with the JUnit 4.x rule API, this module provides -an API that is based on the [JUnit Jupiter](https://junit.org/junit5/) extension model. +This module provides an API that is based on the [JUnit Jupiter](https://junit.org/junit5/) extension model. The extension supports two modes: @@ -58,25 +57,22 @@ using JUnit 5. ## Limitations -Since this module has a dependency onto JUnit Jupiter and on Testcontainers core, which -has a dependency onto JUnit 4.x, projects using this module will end up with both, JUnit Jupiter -and JUnit 4.x in the test classpath. - -This extension has only be tested with sequential test execution. Using it with parallel test execution is unsupported and may have unintended side effects. +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: -```groovy tab='Gradle' -testImplementation "org.testcontainers:junit-jupiter:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - junit-jupiter - {{latest_version}} - test - -``` +=== "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 index 6de3cb47196..e1005397c00 100644 --- a/docs/test_framework_integration/manual_lifecycle_control.md +++ b/docs/test_framework_integration/manual_lifecycle_control.md @@ -1,7 +1,6 @@ # Manual container lifecycle control -While Testcontainers was originally built with JUnit 4 integration in mind, it is fully usable with other test -frameworks, or with no framework at all. +Testcontainers is fully usable with any test framework, or with no framework at all. ## Manually starting/stopping containers diff --git a/docs/test_framework_integration/spock.md b/docs/test_framework_integration/spock.md index 8528faf4490..3d50e0399d2 100644 --- a/docs/test_framework_integration/spock.md +++ b/docs/test_framework_integration/spock.md @@ -17,18 +17,19 @@ can be mixed with Spock's `@Shared` annotation to indicate, that containers shou Add the following dependency to your `pom.xml`/`build.gradle` file: -```groovy tab='Gradle' -testCompile "org.testcontainers:spock:{{latest_version}}" -``` - -```xml tab='Maven' - - org.testcontainers - spock - {{latest_version}} - test - -``` +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-spock:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-spock + {{latest_version}} + test + + ``` ## Attributions 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/examples/build.gradle b/examples/build.gradle index 83e196dac00..e80815cdae6 100644 --- a/examples/build.gradle +++ b/examples/build.gradle @@ -1,10 +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 { - jcenter() + 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 index ddffea03dfe..84dbc39a85e 100644 --- a/examples/cucumber/build.gradle +++ b/examples/cucumber/build.gradle @@ -3,14 +3,25 @@ plugins { } repositories { - jcenter() + mavenCentral() } dependencies { - implementation 'org.seleniumhq.selenium:selenium-remote-driver:3.141.59' - implementation 'org.seleniumhq.selenium:selenium-firefox-driver:3.141.59' - implementation 'org.seleniumhq.selenium:selenium-chrome-driver:3.141.59' - testImplementation 'io.cucumber:cucumber-java:6.9.1' - testImplementation 'io.cucumber:cucumber-junit:6.9.1' - testImplementation 'org.testcontainers:selenium' + 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/testcontainsers/examples/Stepdefs.java b/examples/cucumber/src/test/java/org/testcontainers/examples/Stepdefs.java similarity index 56% rename from examples/cucumber/src/test/java/org/testcontainsers/examples/Stepdefs.java rename to examples/cucumber/src/test/java/org/testcontainers/examples/Stepdefs.java index ca70173ae92..aeccdd183d1 100644 --- a/examples/cucumber/src/test/java/org/testcontainsers/examples/Stepdefs.java +++ b/examples/cucumber/src/test/java/org/testcontainers/examples/Stepdefs.java @@ -1,4 +1,4 @@ -package org.testcontainsers.examples; +package org.testcontainers.examples; import io.cucumber.java.After; import io.cucumber.java.Before; @@ -6,26 +6,28 @@ 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 junit.framework.TestCase.assertEquals; -import static org.testcontainers.containers.BrowserWebDriverContainer.VncRecordingMode.RECORD_ALL; +import static org.assertj.core.api.Assertions.assertThat; public class Stepdefs { private BrowserWebDriverContainer container = new BrowserWebDriverContainer() - .withCapabilities(new ChromeOptions()) - .withRecordingMode(RECORD_ALL, new File("build")); + .withCapabilities(new ChromeOptions()) + .withRecordingMode(VncRecordingMode.RECORD_ALL, new File("build")); private String location; + private String answer; @Before @@ -35,17 +37,20 @@ public void beforeScenario() { @After public void afterScenario(Scenario scenario) { - container.afterTest(new TestDescription() { - @Override - public String getTestId() { - return scenario.getId(); - } + 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())); + @Override + public String getFilesystemFriendlyName() { + return scenario.getName(); + } + }, + Optional.of(scenario).filter(Scenario::isFailed).map(__ -> new RuntimeException()) + ); } @Given("^location is \"([^\"]*)\"$") @@ -55,16 +60,14 @@ public void locationIs(String location) throws Exception { @When("^I ask is it possible to search here$") public void iAskIsItPossibleToSearchHere() throws Exception { - RemoteWebDriver driver = container.getWebDriver(); + RemoteWebDriver driver = new RemoteWebDriver(container.getSeleniumAddress(), new ChromeOptions()); driver.get(location); - List searchInputs = driver.findElementsByTagName("input"); + 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 { - assertEquals(expected, answer); + assertThat(answer).isEqualTo(expected); } - - } diff --git a/examples/cucumber/src/test/java/org/testcontainsers/examples/CucumberTest.java b/examples/cucumber/src/test/java/org/testcontainsers/examples/CucumberTest.java deleted file mode 100644 index 0551b4dbf2e..00000000000 --- a/examples/cucumber/src/test/java/org/testcontainsers/examples/CucumberTest.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.testcontainsers.examples; - -import io.cucumber.junit.Cucumber; -import io.cucumber.junit.CucumberOptions; -import org.junit.runner.RunWith; - -@RunWith(Cucumber.class) -@CucumberOptions(plugin = {"pretty"}) -public class CucumberTest { - - -} diff --git a/modules/dynalite/src/test/resources/logback-test.xml b/examples/cucumber/src/test/resources/logback-test.xml similarity index 88% rename from modules/dynalite/src/test/resources/logback-test.xml rename to examples/cucumber/src/test/resources/logback-test.xml index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/dynalite/src/test/resources/logback-test.xml +++ b/examples/cucumber/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + diff --git a/examples/cucumber/src/test/resources/org/testcontainsers/examples/is_search_possible.feature b/examples/cucumber/src/test/resources/org/testcontainers/examples/is_search_possible.feature similarity index 100% rename from examples/cucumber/src/test/resources/org/testcontainsers/examples/is_search_possible.feature rename to examples/cucumber/src/test/resources/org/testcontainers/examples/is_search_possible.feature diff --git a/examples/disque-job-queue/build.gradle b/examples/disque-job-queue/build.gradle deleted file mode 100644 index c31cb39b7fe..00000000000 --- a/examples/disque-job-queue/build.gradle +++ /dev/null @@ -1,18 +0,0 @@ -plugins { - id 'java' -} - -repositories { - jcenter() -} - -dependencies { - compileOnly "org.projectlombok:lombok:1.18.16" - annotationProcessor "org.projectlombok:lombok:1.18.14" - implementation 'biz.paluch.redis:spinach:0.3' - implementation 'com.google.code.gson:gson:2.8.6' - implementation 'com.google.guava:guava:23.0' - testImplementation 'ch.qos.logback:logback-classic:1.2.3' - testImplementation 'org.mockito:mockito-all:1.10.19' - testImplementation 'org.testcontainers:testcontainers' -} diff --git a/examples/disque-job-queue/src/main/java/DisqueBackedMailSenderService.java b/examples/disque-job-queue/src/main/java/DisqueBackedMailSenderService.java deleted file mode 100644 index d2096106500..00000000000 --- a/examples/disque-job-queue/src/main/java/DisqueBackedMailSenderService.java +++ /dev/null @@ -1,73 +0,0 @@ -import biz.paluch.spinach.DisqueClient; -import biz.paluch.spinach.api.Job; -import biz.paluch.spinach.api.sync.DisqueCommands; -import com.google.gson.Gson; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -/** - * Simple example of how a mail service can be backed by a Disque job queue. - * - * It's assumed that for actual sending (SMTP, HTTP API, etc), this service calls a {@link MailApiClient}. - * However, this can be expected to be quite unreliable, so use of Disque as a backing store helps keep track - * of sent/failed state. - */ -public class DisqueBackedMailSenderService implements MailSenderService { - - private final DisqueCommands disque; - private final Gson gson; - private final MailApiClient mailApiClient; - - public DisqueBackedMailSenderService(DisqueClient client, MailApiClient mailApiClient) { - this.mailApiClient = mailApiClient; - - // Obtain a Disque connection from DisqueClient - disque = client.connect().sync(); - - gson = new Gson(); - } - - @Override - public String enqueueMessage(MailMessage message) { - String messageAsJson = gson.toJson(message); - - // Enqueue the message as JSON, setting a TTL of 1 day (will expire off the queue if not sent by then) - String jobId = disque.addjob("mail", messageAsJson, 1, TimeUnit.DAYS); - - return jobId; - } - - @Override - public Result doScheduledSend() { - - Set succeededJobIds = new HashSet<>(); - Set failedJobIds = new HashSet<>(); - - // Wait up to 100ms for new messages to arrive on the queue - // Retrieve up to 10 messages - // (Ordinarily we'd make these configurable!) - List> jobs = disque.getjobs(100, TimeUnit.MILLISECONDS, 10, "mail"); - - for (Job job : jobs) { - String jsonBody = job.getBody(); - MailMessage message = gson.fromJson(jsonBody, MailMessage.class); - - try { - mailApiClient.send(message); - succeededJobIds.add(job.getId()); - } catch (MailApiClient.MailSendException e) { - failedJobIds.add(job.getId()); - } - } - - // For any failed messages, proactively return to the queue - disque.nack(failedJobIds.toArray(new String[failedJobIds.size()])); - // For any successful jobs, mark as completed - disque.ackjob(succeededJobIds.toArray(new String[succeededJobIds.size()])); - - return new Result(succeededJobIds.size(), failedJobIds.size()); - } -} diff --git a/examples/disque-job-queue/src/main/java/MailApiClient.java b/examples/disque-job-queue/src/main/java/MailApiClient.java deleted file mode 100644 index 61a90112f32..00000000000 --- a/examples/disque-job-queue/src/main/java/MailApiClient.java +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Created by rnorth on 03/01/2016. - */ -public interface MailApiClient { - - boolean send(MailMessage message) throws MailSendException; - - class MailSendException extends RuntimeException { - - } -} diff --git a/examples/disque-job-queue/src/main/java/MailMessage.java b/examples/disque-job-queue/src/main/java/MailMessage.java deleted file mode 100644 index de167199057..00000000000 --- a/examples/disque-job-queue/src/main/java/MailMessage.java +++ /dev/null @@ -1,12 +0,0 @@ -import lombok.AllArgsConstructor; -import lombok.Data; - -/** - * Created by rnorth on 03/01/2016. - */ -@Data @AllArgsConstructor -public class MailMessage { - public final String subject; - public final String recipient; - public final String body; -} diff --git a/examples/disque-job-queue/src/main/java/MailSenderService.java b/examples/disque-job-queue/src/main/java/MailSenderService.java deleted file mode 100644 index 95237bf2eec..00000000000 --- a/examples/disque-job-queue/src/main/java/MailSenderService.java +++ /dev/null @@ -1,45 +0,0 @@ -import lombok.Data; -import lombok.RequiredArgsConstructor; - -import java.util.concurrent.Callable; - -/** - * Created by rnorth on 03/01/2016. - */ -public interface MailSenderService extends Callable { - - /** - * Enqueue a message to be sent on a subsequent {@link DisqueBackedMailSenderService#doScheduledSend()} - * @param message mail message to be sent - * @return an ID for the enqueued message - */ - String enqueueMessage(MailMessage message); - - /** - * Trigger a scheduled send of mail messages. - * - * TODO: In a real implementation this could be called by a {@link java.util.concurrent.ScheduledExecutorService}, - * probably - * - * @return the result of sending queued messages. - */ - MailSenderService.Result doScheduledSend(); - - /** - * call() implementation to allow MailSenderService to be used as a Callable. Simply delegates to - * the {@link DisqueBackedMailSenderService#doScheduledSend()} method. - * - * @return the result of sending queued messages. - * @throws Exception - */ - default MailSenderService.Result call() throws Exception { - return doScheduledSend(); - } - - @Data - @RequiredArgsConstructor - class Result { - public final int successfulCount; - public final int failedCount; - } -} diff --git a/examples/disque-job-queue/src/test/java/DisqueBackedMailSenderTest.java b/examples/disque-job-queue/src/test/java/DisqueBackedMailSenderTest.java deleted file mode 100644 index add59fd15ba..00000000000 --- a/examples/disque-job-queue/src/test/java/DisqueBackedMailSenderTest.java +++ /dev/null @@ -1,99 +0,0 @@ -import biz.paluch.spinach.DisqueClient; -import biz.paluch.spinach.DisqueURI; -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.mockito.Matchers.eq; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.context; -import static org.rnorth.visibleassertions.VisibleAssertions.info; - -/** - * Created by rnorth on 03/01/2016. - */ -public class DisqueBackedMailSenderTest { - - @Rule - public GenericContainer container = new GenericContainer<>(DockerImageName.parse("richnorth/disque:1.0-rc1")) - .withExposedPorts(7711); - private DisqueClient disqueClient; - - private MailSenderService service; - private MailApiClient mockMailApiClient; - - private MailMessage dummyMessage1; - private MailMessage dummyMessage2; - private MailMessage dummyMessage3; - - @Before - public void setup() { - context(""); - disqueClient = new DisqueClient(DisqueURI.create(container.getContainerIpAddress(), container.getMappedPort(7711))); - mockMailApiClient = mock(MailApiClient.class); - - service = new DisqueBackedMailSenderService(disqueClient, mockMailApiClient); - - info("Initialized a fresh Disque instance and service instance"); - - dummyMessage1 = new MailMessage("Dummy Message 1", "bob@example.com", "Test body"); - dummyMessage2 = new MailMessage("Dummy Message 2", "bob@example.com", "Test body"); - dummyMessage3 = new MailMessage("Dummy Message 3", "bob@example.com", "Test body"); - } - - @Test - public void testSimpleSuccessfulSending() throws Exception { - service.enqueueMessage(dummyMessage1); - service.enqueueMessage(dummyMessage2); - service.enqueueMessage(dummyMessage3); - - when(mockMailApiClient.send(any(MailMessage.class))).thenReturn(true); - - service.doScheduledSend(); - - verify(mockMailApiClient).send(eq(dummyMessage1)); - verify(mockMailApiClient).send(eq(dummyMessage2)); - verify(mockMailApiClient).send(eq(dummyMessage3)); - } - - @Test - public void testRetryOnFailure() throws Exception { - service.enqueueMessage(dummyMessage1); - service.enqueueMessage(dummyMessage2); - service.enqueueMessage(dummyMessage3); - - info("Message 1 will fail to send on the first attempt"); - when(mockMailApiClient.send(eq(dummyMessage1))).thenThrow(MailApiClient.MailSendException.class).thenReturn(true); - when(mockMailApiClient.send(eq(dummyMessage2))).thenReturn(true); - when(mockMailApiClient.send(eq(dummyMessage3))).thenReturn(true); - - MailSenderService.Result result; - context("Simulating sending messages"); - context("First sending attempt", 4); - result = service.doScheduledSend(); - assertEquals("2 messages were 'sent' successfully", 2, result.successfulCount); - assertEquals("1 messages failed", 1, result.failedCount); - - context("Second attempt", 4); - result = service.doScheduledSend(); - assertEquals("1 message was 'sent' successfully", 1, result.successfulCount); - assertEquals("0 messages failed", 0, result.failedCount); - - context("Third attempt", 4); - info("No messages should be due to send this time"); - result = service.doScheduledSend(); - assertEquals("0 messages were 'sent' successfully", 0, result.successfulCount); - assertEquals("0 messages failed", 0, result.failedCount); - - verify(mockMailApiClient, times(2)).send(eq(dummyMessage1)); - verify(mockMailApiClient).send(eq(dummyMessage2)); - verify(mockMailApiClient).send(eq(dummyMessage3)); - } -} diff --git a/examples/disque-job-queue/src/test/java/SingleDisqueInstanceTest.java b/examples/disque-job-queue/src/test/java/SingleDisqueInstanceTest.java deleted file mode 100644 index 3fa115bea20..00000000000 --- a/examples/disque-job-queue/src/test/java/SingleDisqueInstanceTest.java +++ /dev/null @@ -1,82 +0,0 @@ -import biz.paluch.spinach.DisqueClient; -import biz.paluch.spinach.DisqueURI; -import biz.paluch.spinach.api.AddJobArgs; -import biz.paluch.spinach.api.Job; -import biz.paluch.spinach.api.sync.DisqueCommands; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.utility.DockerImageName; - -import java.util.concurrent.TimeUnit; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertNotNull; -import static org.rnorth.visibleassertions.VisibleAssertions.context; -import static org.rnorth.visibleassertions.VisibleAssertions.info; - -/** - * Created by rnorth on 03/01/2016. - */ -public class SingleDisqueInstanceTest { - - @Rule - public GenericContainer container = new GenericContainer<>(DockerImageName.parse("richnorth/disque:1.0-rc1")) - .withExposedPorts(7711); - private DisqueCommands connection; - private AddJobArgs retryAfter1Second; - - @Before - public void setup() { - context(""); - DisqueClient client = new DisqueClient(DisqueURI.create(container.getContainerIpAddress(), container.getMappedPort(7711))); - connection = client.connect().sync(); - retryAfter1Second = AddJobArgs.builder().retry(1, TimeUnit.SECONDS).build(); - - info("Initialized a fresh Disque instance and client"); - } - - @Test - public void testJobStoreAndRetrieve() { - - info("Adding a job to the queue"); - connection.addjob("main_queue", "body", 1, TimeUnit.MINUTES); - - info("Getting a job from the queue"); - Job job = connection.getjob("main_queue"); - - assertEquals("The retrieved job is the same as the one that was added", - "body", - job.getBody()); - - info("Acknowledging the job to mark completion"); - connection.ackjob(job.getId()); - } - - @Test - public void testFailureToAckJobBeforeTimeout() throws InterruptedException { - - info("Adding a job to the queue"); - connection.addjob("main_queue", "body", 1, TimeUnit.MINUTES, retryAfter1Second); - - info("Getting a job from the queue"); - Job job = connection.getjob("main_queue"); - - assertEquals("The retrieved job is the same as the one that was added", - "body", - job.getBody()); - - info("Simulating a failure to ack the job before the timeout (1s)"); - TimeUnit.SECONDS.sleep(2); - - info("Attempting to get another job from the queue - the RETRY setting for the job means it should have reappeared"); - // The timeout specified here is how long the command will wait for a job to appear - Job job2 = connection.getjob(5, TimeUnit.SECONDS, "main_queue"); - - assertNotNull("After re-getting the original job is back on the queue", job2); - assertEquals("The retrieved job is the same as the one that was added", - "body", - job2.getBody()); - } -} diff --git a/examples/gradle/wrapper/gradle-wrapper.jar b/examples/gradle/wrapper/gradle-wrapper.jar index 62d4c053550..1b33c55baab 100644 Binary files a/examples/gradle/wrapper/gradle-wrapper.jar 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 index 622ab64a3cb..78cb6e16a49 100644 --- a/examples/gradle/wrapper/gradle-wrapper.properties +++ b/examples/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,8 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip +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 index fbd7c515832..23d15a93670 100755 --- a/examples/gradlew +++ b/examples/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# 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. @@ -15,81 +15,115 @@ # 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 UN*X -## +# +# 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 -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 +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 -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# 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"' +# 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" +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 - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +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" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +132,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + 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" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +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 -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -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" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +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 - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + 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 - i=`expr $i + 1` + # 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 - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# 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 index 5093609d512..5eed7ee8452 100644 --- a/examples/gradlew.bat +++ b/examples/gradlew.bat @@ -1,104 +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 - -@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=. -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%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="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! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@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/disque-job-queue/src/test/resources/logback-test.xml b/examples/hazelcast/src/test/resources/logback-test.xml similarity index 88% rename from examples/disque-job-queue/src/test/resources/logback-test.xml rename to examples/hazelcast/src/test/resources/logback-test.xml index be4ebaed2c8..83ef7a1a3ef 100644 --- a/examples/disque-job-queue/src/test/resources/logback-test.xml +++ b/examples/hazelcast/src/test/resources/logback-test.xml @@ -11,4 +11,6 @@ + + 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/linked-container/src/test/resources/logback-test.xml b/examples/immudb/src/test/resources/logback-test.xml similarity index 88% rename from examples/linked-container/src/test/resources/logback-test.xml rename to examples/immudb/src/test/resources/logback-test.xml index be4ebaed2c8..83ef7a1a3ef 100644 --- a/examples/linked-container/src/test/resources/logback-test.xml +++ b/examples/immudb/src/test/resources/logback-test.xml @@ -11,4 +11,6 @@ + + diff --git a/examples/kafka-cluster/build.gradle b/examples/kafka-cluster/build.gradle index db07c6859f3..67ca7e4e37c 100644 --- a/examples/kafka-cluster/build.gradle +++ b/examples/kafka-cluster/build.gradle @@ -3,15 +3,22 @@ plugins { } repositories { - jcenter() + mavenCentral() } dependencies { - testCompileOnly "org.projectlombok:lombok:1.18.10" - testAnnotationProcessor "org.projectlombok:lombok:1.18.10" - testCompile 'org.testcontainers:kafka' - testCompile 'org.apache.kafka:kafka-clients:2.3.1' - testCompile 'org.assertj:assertj-core:3.14.0' - testCompile 'com.google.guava:guava:23.0' - testCompile 'org.slf4j:slf4j-simple:1.7.30' + 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 index fd5b1a0a0b6..aa22cceadb0 100644 --- a/examples/kafka-cluster/src/test/java/com/example/kafkacluster/KafkaContainerCluster.java +++ b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/KafkaContainerCluster.java @@ -1,22 +1,21 @@ package com.example.kafkacluster; import lombok.SneakyThrows; -import org.rnorth.ducttape.unreliables.Unreliables; +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.lifecycle.Startables; -import org.testcontainers.containers.KafkaContainer; import org.testcontainers.utility.DockerImageName; +import java.time.Duration; import java.util.Collection; -import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.Stream; -import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; /** * Provides an easy way to launch a Kafka cluster with multiple brokers. @@ -24,41 +23,53 @@ 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) { + 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 brokersNum and 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 + ""); - }) - .collect(Collectors.toList()); + 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() { @@ -66,33 +77,35 @@ public Collection getBrokers() { } public String getBootstrapServers() { - return brokers.stream() - .map(KafkaContainer::getBootstrapServers) - .collect(Collectors.joining(",")); + return brokers.stream().map(KafkaContainer::getBootstrapServers).collect(Collectors.joining(",")); } private Stream> allContainers() { - return Stream.concat( - this.brokers.stream(), - Stream.of(this.zookeeper) - ); + return Stream.concat(this.brokers.stream(), Stream.of(this.zookeeper)); } @Override @SneakyThrows public void start() { - Stream startables = this.brokers.stream().map(Startable.class::cast); - Startables.deepStart(startables).get(60, SECONDS); - - Unreliables.retryUntilTrue(30, TimeUnit.SECONDS, () -> { - Container.ExecResult result = this.zookeeper.execInContainer( - "sh", "-c", - "zookeeper-shell zookeeper:" + KafkaContainer.ZOOKEEPER_PORT + " ls /brokers/ids | tail -n 1" - ); - String brokers = result.getStdout(); - - return brokers != null && brokers.split(",").length == this.brokersNum; - }); + // 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 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 index 0756b24d58a..6a66cf0ca2e 100644 --- a/examples/kafka-cluster/src/test/java/com/example/kafkacluster/KafkaContainerClusterTest.java +++ b/examples/kafka-cluster/src/test/java/com/example/kafkacluster/KafkaContainerClusterTest.java @@ -13,8 +13,8 @@ import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.common.serialization.StringDeserializer; import org.apache.kafka.common.serialization.StringSerializer; -import org.junit.Test; -import org.rnorth.ducttape.unreliables.Unreliables; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; import java.time.Duration; import java.util.Collection; @@ -25,13 +25,35 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.tuple; -public class KafkaContainerClusterTest { +class KafkaContainerClusterTest { @Test - public void testKafkaContainerCluster() throws Exception { - try ( - KafkaContainerCluster cluster = new KafkaContainerCluster("5.2.1", 3, 2) - ) { + 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(); @@ -43,24 +65,27 @@ public void testKafkaContainerCluster() throws Exception { protected void testKafkaFunctionality(String bootstrapServers, int partitions, int rf) throws Exception { try ( - AdminClient adminClient = AdminClient.create(ImmutableMap.of( - AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers - )); - + 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() + 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" + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, + bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG, + "tc-" + UUID.randomUUID(), + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, + "earliest" ), new StringDeserializer(), new StringDeserializer() @@ -75,23 +100,19 @@ protected void testKafkaFunctionality(String bootstrapServers, int partitions, i producer.send(new ProducerRecord<>(topicName, "testcontainers", "rulezzz")).get(); - Unreliables.retryUntilTrue(10, TimeUnit.SECONDS, () -> { - ConsumerRecords records = consumer.poll(Duration.ofMillis(100)); + Awaitility + .await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + ConsumerRecords records = consumer.poll(Duration.ofMillis(100)); - if (records.isEmpty()) { - return false; - } - - assertThat(records) - .hasSize(1) - .extracting(ConsumerRecord::topic, ConsumerRecord::key, ConsumerRecord::value) - .containsExactly(tuple(topicName, "testcontainers", "rulezzz")); - - return true; - }); + 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/linked-container/build.gradle b/examples/linked-container/build.gradle deleted file mode 100644 index 5c7279dd09a..00000000000 --- a/examples/linked-container/build.gradle +++ /dev/null @@ -1,16 +0,0 @@ -plugins { - id 'java' -} - -repositories { - jcenter() -} -dependencies { - compileOnly 'org.slf4j:slf4j-api:1.7.30' - implementation 'com.squareup.okhttp3:okhttp:4.9.0' - implementation 'org.json:json:20201115' - testImplementation 'org.postgresql:postgresql:42.2.18' - testImplementation 'ch.qos.logback:logback-classic:1.2.3' - testImplementation 'org.testcontainers:postgresql' -} - diff --git a/examples/linked-container/src/main/java/com/example/linkedcontainer/RedmineClient.java b/examples/linked-container/src/main/java/com/example/linkedcontainer/RedmineClient.java deleted file mode 100644 index be62d932971..00000000000 --- a/examples/linked-container/src/main/java/com/example/linkedcontainer/RedmineClient.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.linkedcontainer; - -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import org.json.JSONObject; - -import java.io.IOException; - -/** - * A crude, partially implemented Redmine client. - */ -public class RedmineClient { - - private String url; - private OkHttpClient client; - - public RedmineClient(String url) { - this.url = url; - client = new OkHttpClient(); - } - - public int getIssueCount() throws IOException { - Request request = new Request.Builder() - .url(url + "/issues.json") - .build(); - - Response response = client.newCall(request).execute(); - JSONObject jsonObject = new JSONObject(response.body().string()); - return jsonObject.getInt("total_count"); - } -} diff --git a/examples/linked-container/src/test/java/com/example/linkedcontainer/LinkedContainerTestImages.java b/examples/linked-container/src/test/java/com/example/linkedcontainer/LinkedContainerTestImages.java deleted file mode 100644 index 8bbbafd189f..00000000000 --- a/examples/linked-container/src/test/java/com/example/linkedcontainer/LinkedContainerTestImages.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.linkedcontainer; - -import org.testcontainers.utility.DockerImageName; - -public interface LinkedContainerTestImages { - DockerImageName POSTGRES_TEST_IMAGE = DockerImageName.parse("postgres:9.6.12"); - DockerImageName REDMINE_TEST_IMAGE = DockerImageName.parse("redmine:3.3.2"); -} diff --git a/examples/linked-container/src/test/java/com/example/linkedcontainer/RedmineClientTest.java b/examples/linked-container/src/test/java/com/example/linkedcontainer/RedmineClientTest.java deleted file mode 100644 index b84b2a9e2c9..00000000000 --- a/examples/linked-container/src/test/java/com/example/linkedcontainer/RedmineClientTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.example.linkedcontainer; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.RuleChain; -import org.testcontainers.containers.PostgreSQLContainer; - -import static org.junit.Assert.assertEquals; - -/** - * Tests for RedmineClient. - */ -public class RedmineClientTest { - - private static final String POSTGRES_USERNAME = "redmine"; - private static final String POSTGRES_PASSWORD = "secret"; - - private PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer<>(LinkedContainerTestImages.POSTGRES_TEST_IMAGE) - .withUsername(POSTGRES_USERNAME) - .withPassword(POSTGRES_PASSWORD); - - private RedmineContainer redmineContainer = new RedmineContainer(LinkedContainerTestImages.REDMINE_TEST_IMAGE) - .withLinkToContainer(postgreSQLContainer, "postgres") - .withEnv("POSTGRES_ENV_POSTGRES_USER", POSTGRES_USERNAME) - .withEnv("POSTGRES_ENV_POSTGRES_PASSWORD", POSTGRES_PASSWORD); - - @Rule - public RuleChain chain = RuleChain.outerRule(postgreSQLContainer) - .around(redmineContainer); - - @Test - public void canGetIssueCount() throws Exception { - RedmineClient redmineClient = new RedmineClient( - redmineContainer.getRedmineUrl()); - - assertEquals( - "The issue count can be retrieved.", - 0, - redmineClient.getIssueCount()); - } -} diff --git a/examples/linked-container/src/test/java/com/example/linkedcontainer/RedmineContainer.java b/examples/linked-container/src/test/java/com/example/linkedcontainer/RedmineContainer.java deleted file mode 100644 index a75867205ab..00000000000 --- a/examples/linked-container/src/test/java/com/example/linkedcontainer/RedmineContainer.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.linkedcontainer; - -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.traits.LinkableContainer; -import org.testcontainers.containers.wait.strategy.Wait; -import org.testcontainers.utility.DockerImageName; - -/** - * A Redmine container. - */ -public class RedmineContainer extends GenericContainer { - - private static final int REDMINE_PORT = 3000; - - public RedmineContainer(DockerImageName dockerImageName) { - super(dockerImageName); - } - - @Override - protected void configure() { - addExposedPort(REDMINE_PORT); - waitingFor(Wait.forHttp("/")); - } - - public RedmineContainer withLinkToContainer(LinkableContainer otherContainer, String alias) { - addLink(otherContainer, alias); - return this; - } - - public String getRedmineUrl() { - return String.format("http://%s:%d", - this.getContainerIpAddress(), - this.getMappedPort(REDMINE_PORT)); - } -} diff --git a/examples/mongodb-container/build.gradle b/examples/mongodb-container/build.gradle deleted file mode 100644 index 57b578a1607..00000000000 --- a/examples/mongodb-container/build.gradle +++ /dev/null @@ -1,13 +0,0 @@ -plugins { - id 'java' -} - -repositories { - jcenter() -} - -dependencies { - testImplementation 'org.testcontainers:testcontainers' - testImplementation 'junit:junit:4.13.1' - testCompileClasspath 'org.jetbrains:annotations:20.1.0' -} diff --git a/examples/mongodb-container/src/test/java/org/testcontainers/containers/MongoDbContainer.java b/examples/mongodb-container/src/test/java/org/testcontainers/containers/MongoDbContainer.java deleted file mode 100644 index c4c07c1f601..00000000000 --- a/examples/mongodb-container/src/test/java/org/testcontainers/containers/MongoDbContainer.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.testcontainers.containers; - -import org.jetbrains.annotations.NotNull; -import org.testcontainers.utility.DockerImageName; - - -/** - * This {@link Container} is based on the official MongoDb ({@code mongo}) image from - * DockerHub. If you need to use a custom MongoDB - * image, you can provide the full image name as well. - * - * @author Stefan Ludwig - */ -public class MongoDbContainer extends GenericContainer { - - /** - * This is the internal port on which MongoDB is running inside the container. - *

- * You can use this constant in case you want to map an explicit public port to it - * instead of the default random port. This can be done using methods like - * {@link #setPortBindings(java.util.List)}. - */ - public static final int MONGODB_PORT = 27017; - public static final String DEFAULT_IMAGE_AND_TAG = "mongo:4.0"; - - /** - * Creates a new {@link MongoDbContainer} with the {@value DEFAULT_IMAGE_AND_TAG} image. - * @deprecated use {@link MongoDbContainer(DockerImageName)} instead - */ - @Deprecated - public MongoDbContainer() { - this(DEFAULT_IMAGE_AND_TAG); - } - - /** - * Creates a new {@link MongoDbContainer} with the given {@code 'image'}. - * - * @param image the image (e.g. {@value DEFAULT_IMAGE_AND_TAG}) to use - * @deprecated use {@link MongoDbContainer(DockerImageName)} instead - */ - public MongoDbContainer(@NotNull String image) { - this(DockerImageName.parse(image)); - } - - /** - * Creates a new {@link MongoDbContainer} with the specified image. - */ - public MongoDbContainer(final DockerImageName dockerImageName) { - super(dockerImageName); - addExposedPort(MONGODB_PORT); - } - - /** - * Returns the actual public port of the internal MongoDB port ({@value MONGODB_PORT}). - * - * @return the public port of this container - * @see #getMappedPort(int) - */ - @NotNull - public Integer getPort() { - return getMappedPort(MONGODB_PORT); - } - -} diff --git a/examples/mongodb-container/src/test/java/org/testcontainers/containers/MongoDbContainerTest.java b/examples/mongodb-container/src/test/java/org/testcontainers/containers/MongoDbContainerTest.java deleted file mode 100644 index 7c87e46bccd..00000000000 --- a/examples/mongodb-container/src/test/java/org/testcontainers/containers/MongoDbContainerTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.testcontainers.containers; - -import org.junit.Test; -import org.testcontainers.utility.DockerImageName; - -import java.io.IOException; -import java.net.Socket; - - -public class MongoDbContainerTest { - - @Test - public void containerStartsAndPublicPortIsAvailable() { - try (MongoDbContainer container = new MongoDbContainer(DockerImageName.parse("mongo:4.0"))) { - container.start(); - assertThatPortIsAvailable(container); - } - } - - private void assertThatPortIsAvailable(MongoDbContainer container) { - try { - new Socket(container.getContainerIpAddress(), container.getPort()); - } catch (IOException e) { - throw new AssertionError("The expected port " + container.getPort() + " is not available!"); - } - } - -} 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 index 76c245d5b51..f0d043ce978 100644 --- a/examples/redis-backed-cache-testng/build.gradle +++ b/examples/redis-backed-cache-testng/build.gradle @@ -3,17 +3,18 @@ plugins { } repositories { - jcenter() + mavenCentral() } dependencies { - compileOnly 'org.slf4j:slf4j-api:1.7.30' - implementation 'redis.clients:jedis:3.4.0' - implementation 'com.google.code.gson:gson:2.8.6' + 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.2.3' - testImplementation 'org.testng:testng:7.3.0' + 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 { diff --git a/examples/redis-backed-cache-testng/src/main/java/com/mycompany/cache/Cache.java b/examples/redis-backed-cache-testng/src/main/java/com/mycompany/cache/Cache.java index 996c31ecac7..f0c7710b355 100644 --- a/examples/redis-backed-cache-testng/src/main/java/com/mycompany/cache/Cache.java +++ b/examples/redis-backed-cache-testng/src/main/java/com/mycompany/cache/Cache.java @@ -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 index 2c80818c150..8349de77869 100644 --- 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 @@ -11,7 +11,9 @@ public class RedisBackedCache implements Cache { private final Jedis jedis; + private final String cacheName; + private final Gson gson; public RedisBackedCache(Jedis jedis, String cacheName) { diff --git a/examples/redis-backed-cache-testng/src/test/java/RedisBackedCacheTest.java b/examples/redis-backed-cache-testng/src/test/java/RedisBackedCacheTest.java index 5d204cf99c4..0f97be3c336 100644 --- a/examples/redis-backed-cache-testng/src/test/java/RedisBackedCacheTest.java +++ b/examples/redis-backed-cache-testng/src/test/java/RedisBackedCacheTest.java @@ -10,22 +10,21 @@ import java.util.Optional; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +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:3.0.6")).withExposedPorts(6379); + private static GenericContainer redis = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) + .withExposedPorts(6379); private Cache cache; @BeforeClass public static void startContainer() { - redis.start(); + redis.start(); } @AfterClass @@ -35,7 +34,7 @@ public static void stopContainer() { @BeforeMethod public void setUp() { - Jedis jedis = new Jedis(redis.getContainerIpAddress(), redis.getMappedPort(6379)); + Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379)); cache = new RedisBackedCache(jedis, "test"); } @@ -45,18 +44,18 @@ 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()); + 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); - assertFalse("When an object that's not in the cache is retrieved, nothing is found", - foundObject.isPresent()); + 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 index be4ebaed2c8..83ef7a1a3ef 100644 --- a/examples/redis-backed-cache-testng/src/test/resources/logback-test.xml +++ b/examples/redis-backed-cache-testng/src/test/resources/logback-test.xml @@ -11,4 +11,6 @@ + + diff --git a/examples/redis-backed-cache/build.gradle b/examples/redis-backed-cache/build.gradle index e63d494bcef..5d20b6175dc 100644 --- a/examples/redis-backed-cache/build.gradle +++ b/examples/redis-backed-cache/build.gradle @@ -3,15 +3,22 @@ plugins { } repositories { - jcenter() + mavenCentral() } dependencies { - compileOnly 'org.slf4j:slf4j-api:1.7.30' - implementation 'redis.clients:jedis:3.4.0' - implementation 'com.google.code.gson:gson:2.8.6' + 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 'junit:junit:4.13.1' - testImplementation 'ch.qos.logback:logback-classic:1.2.3' + 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 index 996c31ecac7..f0c7710b355 100644 --- 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 @@ -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/src/main/java/com/mycompany/cache/RedisBackedCache.java b/examples/redis-backed-cache/src/main/java/com/mycompany/cache/RedisBackedCache.java index 2c80818c150..8349de77869 100644 --- 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 @@ -11,7 +11,9 @@ public class RedisBackedCache implements Cache { private final Jedis jedis; + private final String cacheName; + private final Gson gson; public RedisBackedCache(Jedis jedis, String cacheName) { diff --git a/examples/redis-backed-cache/src/test/java/RedisBackedCacheTest.java b/examples/redis-backed-cache/src/test/java/RedisBackedCacheTest.java index d9d93b8255e..970fc0a58e5 100644 --- a/examples/redis-backed-cache/src/test/java/RedisBackedCacheTest.java +++ b/examples/redis-backed-cache/src/test/java/RedisBackedCacheTest.java @@ -1,52 +1,53 @@ import com.mycompany.cache.Cache; import com.mycompany.cache.RedisBackedCache; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; +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.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; /** * Integration test for Redis-backed cache implementation. */ -public class RedisBackedCacheTest { +@Testcontainers +class RedisBackedCacheTest { + + @Container + public GenericContainer redis = new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) + .withExposedPorts(6379); - @Rule - public GenericContainer redis = new GenericContainer<>(DockerImageName.parse("redis:3.0.6")) - .withExposedPorts(6379); private Cache cache; - @Before - public void setUp() throws Exception { - Jedis jedis = new Jedis(redis.getContainerIpAddress(), redis.getMappedPort(6379)); + @BeforeEach + void setUp() throws Exception { + Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379)); cache = new RedisBackedCache(jedis, "test"); } @Test - public void testFindingAnInsertedValue() { + 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()); + 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 - public void testNotFindingAValueThatWasNotInserted() { + 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()); + 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 index be4ebaed2c8..83ef7a1a3ef 100644 --- a/examples/redis-backed-cache/src/test/resources/logback-test.xml +++ b/examples/redis-backed-cache/src/test/resources/logback-test.xml @@ -11,4 +11,6 @@ + + diff --git a/examples/selenium-container/build.gradle b/examples/selenium-container/build.gradle index 1f05d7b5663..f1a8058e304 100644 --- a/examples/selenium-container/build.gradle +++ b/examples/selenium-container/build.gradle @@ -1,11 +1,11 @@ plugins { id 'java' - id 'org.springframework.boot' version '2.4.1' + id 'org.springframework.boot' version '3.5.6' } apply plugin: 'io.spring.dependency-management' repositories { - jcenter() + mavenCentral() } dependencies { @@ -14,5 +14,11 @@ dependencies { 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:selenium' + 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 index 909a1ec36e1..6c42baef27d 100644 --- a/examples/selenium-container/src/main/java/com/example/DemoApplication.java +++ b/examples/selenium-container/src/main/java/com/example/DemoApplication.java @@ -9,5 +9,4 @@ public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } - } diff --git a/examples/selenium-container/src/test/java/SeleniumContainerTest.java b/examples/selenium-container/src/test/java/SeleniumContainerTest.java index c21d6bf4eca..985f2ef8966 100644 --- a/examples/selenium-container/src/test/java/SeleniumContainerTest.java +++ b/examples/selenium-container/src/test/java/SeleniumContainerTest.java @@ -1,60 +1,66 @@ import com.example.DemoApplication; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; +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.boot.web.server.LocalServerPort; 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.junit4.SpringJUnit4ClassRunner; +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.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.testcontainers.containers.BrowserWebDriverContainer.VncRecordingMode.RECORD_ALL; +import static org.assertj.core.api.Assertions.assertThat; /** * Simple example of plain Selenium usage. */ -@RunWith(SpringJUnit4ClassRunner.class) +@org.testcontainers.junit.jupiter.Testcontainers +@ExtendWith(SpringExtension.class) @SpringBootTest(classes = DemoApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ContextConfiguration(initializers = SeleniumContainerTest.Initializer.class) -public class SeleniumContainerTest { +class SeleniumContainerTest { @LocalServerPort private int port; - @Rule + @Container public BrowserWebDriverContainer chrome = new BrowserWebDriverContainer() - .withCapabilities(new ChromeOptions()) - .withRecordingMode(RECORD_ALL, new File("build")); + .withCapabilities(new ChromeOptions()) + .withRecordingMode(VncRecordingMode.RECORD_ALL, new File("build")); @Test - public void simplePlainSeleniumTest() { - RemoteWebDriver driver = chrome.getWebDriver(); + 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.findElementsByTagName("h"); + List hElement = driver.findElements(By.tagName("h")); - assertTrue("The h element is found", hElement != null && hElement.size() > 0); + assertThat(hElement).as("The h element is found").isNotEmpty(); } - public static class Initializer implements ApplicationContextInitializer { + static class Initializer implements ApplicationContextInitializer { + @Override public void initialize(ConfigurableApplicationContext applicationContext) { - applicationContext.addApplicationListener((ApplicationListener) event -> { - Testcontainers.exposeHostPorts(event.getWebServer().getPort()); - }); + 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 index be4ebaed2c8..83ef7a1a3ef 100644 --- a/examples/selenium-container/src/test/resources/logback-test.xml +++ b/examples/selenium-container/src/test/resources/logback-test.xml @@ -11,4 +11,6 @@ + + diff --git a/examples/settings.gradle b/examples/settings.gradle index ea988b4ada8..b9b58a917bf 100644 --- a/examples/settings.gradle +++ b/examples/settings.gradle @@ -5,23 +5,21 @@ buildscript { } } dependencies { - classpath "gradle.plugin.ch.myniva.gradle:s3-build-cache:0.10.0" - classpath "com.gradle.enterprise:com.gradle.enterprise.gradle.plugin:3.2.1" + 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: 'ch.myniva.s3-build-cache' -apply plugin: 'com.gradle.enterprise' +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 'disque-job-queue' include 'kafka-cluster' -include 'linked-container' -include 'mongodb-container' +include 'neo4j-container' include 'redis-backed-cache' include 'redis-backed-cache-testng' include 'selenium-container' @@ -30,25 +28,33 @@ include 'solr-container' include 'spring-boot' include 'cucumber' include 'spring-boot-kotlin-redis' -include 'spock' +include 'immudb' +include 'zookeeper' +include 'hazelcast' +include 'nats' +include 'sftp' +include 'ollama-hugging-face' -ext.isMasterBuild = false || - (System.getenv("GITHUB_REF") == "refs/heads/master") || - (System.getenv("BUILD_SOURCEBRANCHNAME") == "master") +ext.isCI = System.getenv("CI") != null buildCache { local { - enabled = !isMasterBuild + enabled = !isCI } + remote(develocity.buildCache) { + push = isCI && !System.getenv("READ_ONLY_REMOTE_GRADLE_CACHE") && System.getenv("DEVELOCITY_ACCESS_KEY") + enabled = true + } +} - remote(ch.myniva.gradle.caching.s3.AwsS3BuildCache) { - endpoint = 'fra1.digitaloceanspaces.com' - region = 'fra1' - bucket = 'testcontainers' - path = 'cache' - push = isMasterBuild && !System.getenv("READ_ONLY_REMOTE_GRADLE_CACHE") - headers = [ - 'x-amz-acl': 'public-read' - ] +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 index bbd35cabc49..5db92c84a49 100644 --- a/examples/singleton-container/build.gradle +++ b/examples/singleton-container/build.gradle @@ -3,16 +3,23 @@ plugins { } repositories { - jcenter() + mavenCentral() } dependencies { - implementation 'redis.clients:jedis:3.4.0' - implementation 'com.google.code.gson:gson:2.8.6' + 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.30' + compileOnly 'org.slf4j:slf4j-api:1.7.36' - testImplementation 'ch.qos.logback:logback-classic:1.2.3' + 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 index 986838d20f9..8a0c5776d80 100644 --- a/examples/singleton-container/src/main/java/com/example/cache/Cache.java +++ b/examples/singleton-container/src/main/java/com/example/cache/Cache.java @@ -3,8 +3,7 @@ import java.util.Optional; public interface Cache { - void put(String key, Object value); Optional get(String key, Class expectedClass); -} \ No newline at end of file +} 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 index 34553dde1f4..430afb2b1f5 100644 --- a/examples/singleton-container/src/main/java/com/example/cache/RedisBackedCache.java +++ b/examples/singleton-container/src/main/java/com/example/cache/RedisBackedCache.java @@ -8,7 +8,9 @@ public class RedisBackedCache implements Cache { private final Jedis jedis; + private final String cacheName; + private final Gson gson; public RedisBackedCache(Jedis jedis, String cacheName) { diff --git a/examples/singleton-container/src/test/java/com/example/AbstractIntegrationTest.java b/examples/singleton-container/src/test/java/com/example/AbstractIntegrationTest.java index d152540fb1c..654977d7a22 100644 --- a/examples/singleton-container/src/test/java/com/example/AbstractIntegrationTest.java +++ b/examples/singleton-container/src/test/java/com/example/AbstractIntegrationTest.java @@ -5,8 +5,8 @@ public abstract class AbstractIntegrationTest { - public static final GenericContainer redis = new GenericContainer<>(DockerImageName.parse("redis:3.0.6")) - .withExposedPorts(6379); + 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 index 9923b08f1e0..da7b9a2e1cf 100644 --- a/examples/singleton-container/src/test/java/com/example/BarConcreteTestClass.java +++ b/examples/singleton-container/src/test/java/com/example/BarConcreteTestClass.java @@ -2,33 +2,33 @@ import com.example.cache.Cache; import com.example.cache.RedisBackedCache; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import redis.clients.jedis.Jedis; import java.util.Optional; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; -public class BarConcreteTestClass extends AbstractIntegrationTest { +class BarConcreteTestClass extends AbstractIntegrationTest { private Cache cache; - @Before - public void setUp() { - Jedis jedis = new Jedis(redis.getContainerIpAddress(), redis.getMappedPort(6379)); + @BeforeEach + void setUp() { + Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379)); cache = new RedisBackedCache(jedis, "bar"); } @Test - public void testInsertValue() { + void testInsertValue() { cache.put("bar", "BAR"); Optional foundObject = cache.get("bar", String.class); - assertTrue("When inserting an object into the cache, it can be retrieved", foundObject.isPresent()); - assertEquals("When accessing the value of a retrieved object, the value must be the same", "BAR", foundObject.get()); + 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 index a57b0d5d4a0..cd17e3c4ae1 100644 --- a/examples/singleton-container/src/test/java/com/example/FooConcreteTestClass.java +++ b/examples/singleton-container/src/test/java/com/example/FooConcreteTestClass.java @@ -2,32 +2,33 @@ import com.example.cache.Cache; import com.example.cache.RedisBackedCache; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import redis.clients.jedis.Jedis; import java.util.Optional; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; -public class FooConcreteTestClass extends AbstractIntegrationTest { +class FooConcreteTestClass extends AbstractIntegrationTest { private Cache cache; - @Before - public void setUp() throws Exception { - Jedis jedis = new Jedis(redis.getContainerIpAddress(), redis.getMappedPort(6379)); + @BeforeEach + void setUp() { + Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379)); cache = new RedisBackedCache(jedis, "foo"); } @Test - public void testInsertValue() { + void testInsertValue() { cache.put("foo", "FOO"); Optional foundObject = cache.get("foo", String.class); - assertTrue("When inserting an object into the cache, it can be retrieved", foundObject.isPresent()); - assertEquals("When accessing the value of a retrieved object, the value must be the same", "FOO", foundObject.get()); + 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 index fcdf93a9c53..d0d3944cc0a 100644 --- a/examples/solr-container/build.gradle +++ b/examples/solr-container/build.gradle @@ -3,16 +3,22 @@ plugins { } repositories { - jcenter() + mavenCentral() } dependencies { - compileOnly "org.projectlombok:lombok:1.18.16" - annotationProcessor "org.projectlombok:lombok:1.18.16" + compileOnly "org.projectlombok:lombok:1.18.38" + annotationProcessor "org.projectlombok:lombok:1.18.38" - implementation 'org.apache.solr:solr-solrj:8.7.0' + implementation 'org.apache.solr:solr-solrj:8.11.4' testImplementation 'org.testcontainers:testcontainers' - testImplementation 'org.testcontainers:solr' + 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 index c05bc23aad2..397327fe369 100644 --- a/examples/solr-container/src/main/java/com/example/SearchEngine.java +++ b/examples/solr-container/src/main/java/com/example/SearchEngine.java @@ -1,6 +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 index 06544f0ba34..89722d1a5a4 100644 --- a/examples/solr-container/src/main/java/com/example/SearchResult.java +++ b/examples/solr-container/src/main/java/com/example/SearchResult.java @@ -1,13 +1,13 @@ package com.example; -import java.util.List; -import java.util.Map; - import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; +import java.util.Map; + @Data @Builder @NoArgsConstructor diff --git a/examples/solr-container/src/main/java/com/example/SolrSearchEngine.java b/examples/solr-container/src/main/java/com/example/SolrSearchEngine.java index 8a7f38e455d..3be96f76690 100644 --- a/examples/solr-container/src/main/java/com/example/SolrSearchEngine.java +++ b/examples/solr-container/src/main/java/com/example/SolrSearchEngine.java @@ -1,16 +1,15 @@ package com.example; -import java.util.stream.Collectors; - 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 { @@ -20,7 +19,6 @@ public class SolrSearchEngine implements SearchEngine { @SneakyThrows public SearchResult search(String term) { - SolrQuery query = new SolrQuery(); query.setQuery("title:" + ClientUtils.escapeQueryChars(term)); QueryResponse response = client.query(COLLECTION_NAME, query); @@ -28,12 +26,10 @@ public SearchResult search(String term) { } private SearchResult createResult(QueryResponse response) { - return SearchResult.builder() + return SearchResult + .builder() .totalHits(response.getResults().getNumFound()) - .results(response.getResults() - .stream() - .map(SolrDocument::getFieldValueMap) - .collect(Collectors.toList())) + .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 index 63ac67ac1cd..e8bb6876a92 100644 --- a/examples/solr-container/src/test/java/com/example/SolrQueryTest.java +++ b/examples/solr-container/src/test/java/com/example/SolrQueryTest.java @@ -5,8 +5,8 @@ import org.apache.solr.client.solrj.impl.Http2SolrClient; import org.apache.solr.common.SolrInputDocument; import org.apache.solr.common.SolrInputField; -import org.junit.BeforeClass; -import org.junit.Test; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; import org.testcontainers.containers.SolrContainer; import org.testcontainers.utility.DockerImageName; @@ -15,65 +15,77 @@ import java.util.HashMap; import java.util.Map; -import static com.example.SolrSearchEngine.COLLECTION_NAME; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; -public class SolrQueryTest { +class SolrQueryTest { private static final DockerImageName SOLR_IMAGE = DockerImageName.parse("solr:8.3.0"); public static final SolrContainer solrContainer = new SolrContainer(SOLR_IMAGE) - .withCollection(COLLECTION_NAME); + .withCollection(SolrSearchEngine.COLLECTION_NAME); private static SolrClient solrClient; - @BeforeClass - public static void setUp() throws IOException, SolrServerException { + @BeforeAll + static void setUp() throws IOException, SolrServerException { solrContainer.start(); - solrClient = new Http2SolrClient.Builder("http://" + solrContainer.getContainerIpAddress() + ":" + solrContainer.getSolrPort() + "/solr").build(); + solrClient = + new Http2SolrClient.Builder( + "http://" + solrContainer.getHost() + ":" + solrContainer.getSolrPort() + "/solr" + ) + .build(); // Add Sample Data - solrClient.add(COLLECTION_NAME, Collections.singletonList( - new SolrInputDocument(createMap( - "id", createInputField("id", "1"), - "title", createInputField("title", "old skool - trainers - shoes") - )) - )); - - solrClient.add(COLLECTION_NAME, Collections.singletonList( - new SolrInputDocument(createMap( - "id", createInputField("id", "2"), - "title", createInputField("title", "print t-shirt") - )) - )); - - solrClient.commit(COLLECTION_NAME); + 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 - public void testQueryForShoes() { + void testQueryForShoes() { SolrSearchEngine searchEngine = new SolrSearchEngine(solrClient); SearchResult result = searchEngine.search("shoes"); - assertEquals("When searching for shoes we expect one result", 1L, result.getTotalHits()); - assertEquals("The result should have the id 1", "1", result.getResults().get(0).get("id")); + 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 - public void testQueryForTShirt() { + void testQueryForTShirt() { SolrSearchEngine searchEngine = new SolrSearchEngine(solrClient); SearchResult result = searchEngine.search("t-shirt"); - assertEquals("When searching for t-shirt we expect one result", 1L, result.getTotalHits()); - assertEquals("The result should have the id 2", "2", result.getResults().get(0).get("id")); + 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 - public void testQueryForAsterisk() { + void testQueryForAsterisk() { SolrSearchEngine searchEngine = new SolrSearchEngine(solrClient); SearchResult result = searchEngine.search("*"); - assertEquals("When searching for * we expect no results", 0L, result.getTotalHits()); + assertThat(result.getTotalHits()).as("When searching for * we expect no results").isEqualTo(0L); } private static SolrInputField createInputField(String key, String value) { 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 b/examples/spring-boot-kotlin-redis/build.gradle.kts similarity index 51% rename from examples/spring-boot-kotlin-redis/build.gradle rename to examples/spring-boot-kotlin-redis/build.gradle.kts index 4675f907235..2d5d6bf9640 100644 --- a/examples/spring-boot-kotlin-redis/build.gradle +++ b/examples/spring-boot-kotlin-redis/build.gradle.kts @@ -1,16 +1,19 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { - id("org.springframework.boot") version "2.4.1" - id("org.jetbrains.kotlin.jvm") version "1.4.21-2" - id("org.jetbrains.kotlin.plugin.spring") version "1.4.21-2" + id("org.springframework.boot") version "2.7.18" + kotlin("jvm") version "1.8.22" + kotlin("plugin.spring") version "1.8.22" } -apply plugin: 'io.spring.dependency-management' +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") @@ -18,11 +21,16 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.testcontainers:testcontainers") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.8.2") } -compileKotlin { +tasks.withType { kotlinOptions { + freeCompilerArgs = listOf("-Xjsr305=strict") jvmTarget = "1.8" - freeCompilerArgs = ["-Xjsr305=strict"] } -} \ No newline at end of file +} + +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 index bbe3083935e..b890c301242 100644 --- 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 @@ -1,6 +1,5 @@ package com.example.redis -import org.springframework.beans.factory.annotation.Autowired import org.springframework.data.redis.core.RedisTemplate import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping @@ -8,12 +7,13 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RestController @RestController -class ExampleController { +class ExampleController( + private val redisTemplate: RedisTemplate +) { - private val key = "testcontainers" - - @Autowired - private lateinit var redisTemplate: RedisTemplate + companion object { + const val key = "testcontainers" + } @PostMapping("/set-foo") fun setFoo(@RequestBody value: String) { 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 index 6350ace9bd9..6df81f56262 100644 --- 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 @@ -1,23 +1,22 @@ package com.example.redis -import org.junit.runner.RunWith +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.junit4.SpringRunner +import org.springframework.test.context.junit.jupiter.SpringExtension import org.testcontainers.containers.GenericContainer -@RunWith(SpringRunner::class) @SpringBootTest @ContextConfiguration(initializers = [AbstractIntegrationTest.Initializer::class]) @AutoConfigureMockMvc abstract class AbstractIntegrationTest { companion object { - val redisContainer = GenericContainer("redis:3-alpine") + val redisContainer = GenericContainer("redis:6-alpine") .apply { withExposedPorts(6379) } } @@ -26,7 +25,7 @@ abstract class AbstractIntegrationTest { redisContainer.start() TestPropertyValues.of( - "spring.redis.host=${redisContainer.containerIpAddress}", + "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 index aec5a486120..83ab75213ac 100644 --- 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 @@ -1,6 +1,6 @@ package com.example.redis -import org.junit.Test +import org.junit.jupiter.api.Test class RedisApplicationTests : AbstractIntegrationTest() { 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 index 317916bd3e6..908bbd41c9c 100644 --- 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 @@ -1,14 +1,13 @@ package com.example.redis import org.hamcrest.CoreMatchers.containsString -import org.junit.Test +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 -import org.springframework.test.web.servlet.setup.MockMvcBuilders class RedisTest : AbstractIntegrationTest() { 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 index 288a9226b90..e3a40d27f06 100644 --- a/examples/spring-boot/build.gradle +++ b/examples/spring-boot/build.gradle @@ -1,11 +1,11 @@ plugins { id 'java' - id 'org.springframework.boot' version '2.4.1' + id 'org.springframework.boot' version '2.7.18' } apply plugin: 'io.spring.dependency-management' repositories { - jcenter() + mavenCentral() } dependencies { @@ -16,5 +16,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' runtimeOnly 'org.postgresql:postgresql' testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.testcontainers:postgresql' + 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 index c968eb42b78..af96419ad6a 100644 --- a/examples/spring-boot/src/main/java/com/example/DemoApplication.java +++ b/examples/spring-boot/src/main/java/com/example/DemoApplication.java @@ -8,8 +8,7 @@ @EnableJpaRepositories public class DemoApplication { - public static void main(String[] args) { - SpringApplication.run(DemoApplication.class, args); - } - + 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 index 79f48711cad..e01f3527a6d 100644 --- a/examples/spring-boot/src/main/java/com/example/DemoController.java +++ b/examples/spring-boot/src/main/java/com/example/DemoController.java @@ -1,12 +1,17 @@ package com.example; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.web.bind.annotation.*; +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) { diff --git a/examples/spring-boot/src/main/java/com/example/DemoEntity.java b/examples/spring-boot/src/main/java/com/example/DemoEntity.java index 47c0fa94f75..71fceb2c8e8 100644 --- a/examples/spring-boot/src/main/java/com/example/DemoEntity.java +++ b/examples/spring-boot/src/main/java/com/example/DemoEntity.java @@ -7,7 +7,6 @@ import javax.persistence.GeneratedValue; import javax.persistence.Id; - @Data @Entity public class DemoEntity { diff --git a/examples/spring-boot/src/main/java/com/example/DemoRepository.java b/examples/spring-boot/src/main/java/com/example/DemoRepository.java index 89f3775deb0..8ed3216cc18 100644 --- a/examples/spring-boot/src/main/java/com/example/DemoRepository.java +++ b/examples/spring-boot/src/main/java/com/example/DemoRepository.java @@ -1,7 +1,5 @@ package com.example; - import org.springframework.data.jpa.repository.JpaRepository; -public interface DemoRepository extends 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 index 18e8690a99f..78e8c41e907 100644 --- a/examples/spring-boot/src/main/java/com/example/DemoService.java +++ b/examples/spring-boot/src/main/java/com/example/DemoService.java @@ -4,15 +4,14 @@ @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")); + return demoRepository.findById(id).orElseThrow(() -> new RuntimeException("Entity not found")); } } diff --git a/examples/spring-boot/src/test/java/com/example/AbstractIntegrationTest.java b/examples/spring-boot/src/test/java/com/example/AbstractIntegrationTest.java index b6859563c69..806e63ed930 100644 --- a/examples/spring-boot/src/test/java/com/example/AbstractIntegrationTest.java +++ b/examples/spring-boot/src/test/java/com/example/AbstractIntegrationTest.java @@ -1,29 +1,28 @@ package com.example; -import org.junit.runner.RunWith; 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.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.testcontainers.containers.GenericContainer; import org.testcontainers.utility.DockerImageName; -@RunWith(SpringJUnit4ClassRunner.class) -@SpringBootTest(classes = DemoApplication.class, webEnvironment = WebEnvironment.RANDOM_PORT, properties = { - "spring.datasource.url=jdbc:tc:postgresql:11-alpine:///databasename", -}) +@SpringBootTest( + classes = DemoApplication.class, + webEnvironment = WebEnvironment.RANDOM_PORT, + properties = { "spring.datasource.url=jdbc:tc:postgresql:11-alpine:///databasename" } +) @ActiveProfiles("test") -public abstract class AbstractIntegrationTest { +abstract class AbstractIntegrationTest { - static GenericContainer redis = new GenericContainer<>(DockerImageName.parse("redis:3-alpine")) + 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::getContainerIpAddress); + 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 index 286ce41ffbf..9e96f1ab1e8 100644 --- a/examples/spring-boot/src/test/java/com/example/DemoControllerTest.java +++ b/examples/spring-boot/src/test/java/com/example/DemoControllerTest.java @@ -1,13 +1,12 @@ package com.example; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.web.client.TestRestTemplate; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.info; +import static org.assertj.core.api.Assertions.assertThat; -public class DemoControllerTest extends AbstractIntegrationTest { +class DemoControllerTest extends AbstractIntegrationTest { @Autowired TestRestTemplate restTemplate; @@ -16,24 +15,22 @@ public class DemoControllerTest extends AbstractIntegrationTest { DemoRepository demoRepository; @Test - public void simpleTest() { + void simpleTest() { String fooResource = "/foo"; - info("putting 'bar' to " + fooResource); restTemplate.put(fooResource, "bar"); - assertEquals("value is set", "bar", restTemplate.getForObject(fooResource, String.class)); + assertThat(restTemplate.getForObject(fooResource, String.class)).as("value is set").isEqualTo("bar"); } @Test - public void simpleJPATest() { + void simpleJPATest() { DemoEntity demoEntity = new DemoEntity(); demoEntity.setValue("Some value"); demoRepository.save(demoEntity); DemoEntity result = restTemplate.getForObject("/" + demoEntity.getId(), DemoEntity.class); - assertEquals("value is set", "Some value", result.getValue()); + 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 index 5431cb6200b..83ef7a1a3ef 100644 --- a/examples/spring-boot/src/test/resources/logback-test.xml +++ b/examples/spring-boot/src/test/resources/logback-test.xml @@ -1,3 +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 index dad41a4425a..f9e383482f4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +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/bintray.gradle b/gradle/bintray.gradle deleted file mode 100644 index 5ddfefd8e49..00000000000 --- a/gradle/bintray.gradle +++ /dev/null @@ -1,24 +0,0 @@ -apply plugin: "com.jfrog.bintray" - -bintray { - user = project.hasProperty('bintrayUser') ? project.property('bintrayUser') : System.getenv('BINTRAY_USER') - key = project.hasProperty('bintrayApiKey') ? project.property('bintrayApiKey') : System.getenv('BINTRAY_API_KEY') - - publications = ['mavenJava'] - - pkg { - userOrg = 'testcontainers' - repo = 'releases' - name = 'testcontainers' - - publish = true - override = true - - version { - name = project.version - vcsTag = project.version - released = new Date() - gpg.sign = true - } - } -} diff --git a/gradle/ci-support.gradle b/gradle/ci-support.gradle index b878c107796..5b85837361e 100644 --- a/gradle/ci-support.gradle +++ b/gradle/ci-support.gradle @@ -17,7 +17,7 @@ task testMatrix { } } -// If we're executing the `taskMatrix` task, disable tests and other slow tasks +// 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)) { 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 index 3acdeb10c88..3e3faaf08ce 100644 --- a/gradle/publishing.gradle +++ b/gradle/publishing.gradle @@ -1,24 +1,29 @@ apply plugin: 'maven-publish' +apply plugin: 'org.jreleaser' task sourceJar(type: Jar) { - classifier = 'sources' + archiveClassifier.set( 'sources') from sourceSets.main.allJava } task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = 'javadoc' + archiveClassifier.set('javadoc') from javadoc } +jar.archiveClassifier.set("original") + publishing { publications { mavenJava(MavenPublication) { publication -> artifactId = project.name - - artifact project.tasks.jar 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() + { @@ -26,7 +31,7 @@ publishing { name project.description description 'Isolated container management for Java code testing' - url 'https://testcontainers.org' + url 'https://java.testcontainers.org' issueManagement { system 'GitHub' url 'https://github.com/testcontainers/testcontainers-java/issues' @@ -53,27 +58,65 @@ publishing { def dependenciesNode = rootNode.appendNode('dependencies') - def addDependency = { dependency, scope -> - dependenciesNode.appendNode('dependency').with { - appendNode('groupId', dependency.group) - appendNode('artifactId', dependency.name) - appendNode('version', dependency.version) - appendNode('scope', scope) + 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) + 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') + } + } +} - project.configurations.compile.dependencies.each { addDependency(it, 'compile') } - project.configurations.provided.dependencies.each { addDependency(it, 'provided') } +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 index 5c2d1cf016b..1b33c55baab 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 6fd47cc3de5..dbc089ed3d7 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,8 @@ -#Sun Apr 05 19:28:23 BST 2020 -distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-all.zip distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -zipStorePath=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 index 2fe81a7d95e..23d15a93670 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# 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. @@ -15,80 +15,115 @@ # 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 UN*X -## +# +# 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 -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 +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 -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# 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"' +# 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" +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 - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +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" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -97,87 +132,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + 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" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +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 -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -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" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +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 - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + 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 - i=`expr $i + 1` + # 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 - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# 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 index 24467a141f7..5eed7ee8452 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @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 +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,10 +27,14 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +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" @@ -37,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +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 @@ -51,48 +57,36 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +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 -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%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%"=="0" goto mainEnd +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! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +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 diff --git a/mkdocs.yml b/mkdocs.yml index 82f2d291164..ae447212912 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,18 +1,22 @@ -site_name: Testcontainers +site_name: Testcontainers for Java +site_url: https://java.testcontainers.org plugins: - search - codeinclude - markdownextradata theme: name: 'material' + custom_dir: 'docs/theme' palette: - primary: 'blue' - accent: 'teal' - font: false + 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: @@ -20,6 +24,8 @@ markdown_extensions: - codehilite: linenums: False - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true - pymdownx.snippets nav: - Home: index.md @@ -35,9 +41,11 @@ nav: - 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 @@ -47,44 +55,81 @@ nav: - 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/dynalite.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 @@ -92,6 +137,6 @@ nav: - Contributing: - contributing.md - contributing_docs.md - - bounty.md +edit_uri: edit/main/docs/ extra: - latest_version: 1.15.2 + 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/cassandra/build.gradle b/modules/cassandra/build.gradle index 9704a05b69d..b0bea00226e 100644 --- a/modules/cassandra/build.gradle +++ b/modules/cassandra/build.gradle @@ -1,6 +1,14 @@ -description = "TestContainers :: Cassandra" +description = "Testcontainers :: Cassandra" + +configurations.all { + resolutionStrategy { + force 'io.dropwizard.metrics:metrics-core:3.2.6' + } +} dependencies { - compile project(":database-commons") - compile "com.datastax.cassandra:cassandra-driver-core:3.7.1" + 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 index 597b1c9b017..fcaada177ce 100644 --- a/modules/cassandra/src/main/java/org/testcontainers/containers/CassandraContainer.java +++ b/modules/cassandra/src/main/java/org/testcontainers/containers/CassandraContainer.java @@ -10,34 +10,47 @@ import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.MountableFile; -import javax.script.ScriptException; 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; + /** - * Cassandra container - * - * Supports 2.x and 3.x Cassandra versions + * Testcontainers implementation for Apache Cassandra. + *

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

+ * Exposed ports: 9042 * - * @author Eugeny Karpov + * @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; /** @@ -54,12 +67,17 @@ public CassandraContainer(String dockerImageName) { public CassandraContainer(DockerImageName dockerImageName) { super(dockerImageName); - dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); addExposedPort(CQL_PORT); - setStartupAttempts(3); 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 @@ -81,7 +99,9 @@ private void runInitScriptIfRequired() { 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."); + throw new ScriptLoadException( + "Could not load classpath init script: " + initScriptPath + ". Resource not found." + ); } String cql = IOUtils.toString(resource, StandardCharsets.UTF_8); DatabaseDelegate databaseDelegate = getDatabaseDelegate(); @@ -91,7 +111,10 @@ private void runInitScriptIfRequired() { 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); + throw new ScriptUtils.UncategorizedScriptException( + "Error while executing init script: " + initScriptPath, + e + ); } } } @@ -105,9 +128,10 @@ private void runInitScriptIfRequired() { * @param resourceLocation relative classpath to resource */ protected void optionallyMapResourceParameterAsVolume(String pathNameInContainer, String resourceLocation) { - Optional.ofNullable(resourceLocation) - .map(MountableFile::forClasspathResource) - .ifPresent(mountableFile -> withCopyFileToContainer(mountableFile, pathNameInContainer)); + Optional + .ofNullable(resourceLocation) + .map(MountableFile::forClasspathResource) + .ifPresent(mountableFile -> withCopyFileToContainer(mountableFile, pathNameInContainer)); } /** @@ -171,13 +195,23 @@ public String getPassword() { * 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() + final Cluster.Builder builder = Cluster + .builder() .addContactPoint(containerState.getHost()) .withPort(containerState.getMappedPort(CQL_PORT)); if (!enableJmxReporting) { @@ -186,10 +220,30 @@ public static Cluster getCluster(ContainerState containerState, boolean enableJm 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 index e55dd31b0ff..7b7739490ea 100644 --- a/modules/cassandra/src/main/java/org/testcontainers/containers/delegate/CassandraDatabaseDelegate.java +++ b/modules/cassandra/src/main/java/org/testcontainers/containers/delegate/CassandraDatabaseDelegate.java @@ -14,10 +14,11 @@ /** * Cassandra database delegate * - * @author Eugeny Karpov + * @deprecated use {@link org.testcontainers.cassandra.CassandraDatabaseDelegate} instead. */ @Slf4j @RequiredArgsConstructor +@Deprecated public class CassandraDatabaseDelegate extends AbstractDatabaseDelegate { private final ContainerState container; @@ -25,8 +26,7 @@ public class CassandraDatabaseDelegate extends AbstractDatabaseDelegate @Override protected Session createNewConnection() { try { - return CassandraContainer.getCluster(container) - .newSession(); + return CassandraContainer.getCluster(container).newSession(); } catch (DriverException e) { log.error("Could not obtain cassandra connection"); throw new ConnectionCreationException("Could not obtain cassandra connection", e); @@ -34,7 +34,13 @@ protected Session createNewConnection() { } @Override - public void execute(String statement, String scriptPath, int lineNumber, boolean continueOnError, boolean ignoreFailedDrops) { + public void execute( + String statement, + String scriptPath, + int lineNumber, + boolean continueOnError, + boolean ignoreFailedDrops + ) { try { ResultSet result = getConnection().execute(statement); if (result.wasApplied()) { 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 index cf7f3769985..9694711de6e 100644 --- a/modules/cassandra/src/main/java/org/testcontainers/containers/wait/CassandraQueryWaitStrategy.java +++ b/modules/cassandra/src/main/java/org/testcontainers/containers/wait/CassandraQueryWaitStrategy.java @@ -13,25 +13,32 @@ /** * Waits until Cassandra returns its version * - * @author Eugeny Karpov + * @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; - }); + 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); } 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 index 76c8c8438d7..2d534bccc81 100644 --- a/modules/cassandra/src/test/java/org/testcontainers/containers/CassandraContainerTest.java +++ b/modules/cassandra/src/test/java/org/testcontainers/containers/CassandraContainerTest.java @@ -5,70 +5,74 @@ import com.datastax.driver.core.Row; import com.datastax.driver.core.Session; import lombok.extern.slf4j.Slf4j; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.testcontainers.containers.wait.CassandraQueryWaitStrategy; import org.testcontainers.utility.DockerImageName; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; -/** - * @author Eugeny Karpov - */ @Slf4j -public class CassandraContainerTest { +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 - public void testSimple() { + void testSimple() { try (CassandraContainer cassandraContainer = new CassandraContainer<>(CASSANDRA_IMAGE)) { cassandraContainer.start(); - ResultSet resultSet = performQuery(cassandraContainer, "SELECT release_version FROM system.local"); - assertTrue("Query was not applied", resultSet.wasApplied()); - assertNotNull("Result set has no release_version", resultSet.one().getString(0)); + 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 - public void testSpecificVersion() { + void testSpecificVersion() { String cassandraVersion = "3.0.15"; - try (CassandraContainer cassandraContainer = new CassandraContainer<>(CASSANDRA_IMAGE.withTag(cassandraVersion))) { + try ( + CassandraContainer cassandraContainer = new CassandraContainer<>( + CASSANDRA_IMAGE.withTag(cassandraVersion) + ) + ) { cassandraContainer.start(); - ResultSet resultSet = performQuery(cassandraContainer, "SELECT release_version FROM system.local"); - assertTrue("Query was not applied", resultSet.wasApplied()); - assertEquals("Cassandra has wrong version", cassandraVersion, resultSet.one().getString(0)); + 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 - public void testConfigurationOverride() { + 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"); - assertTrue("Query was not applied", resultSet.wasApplied()); - assertEquals("Cassandra configuration is not overridden", TEST_CLUSTER_NAME_IN_CONF, resultSet.one().getString(0)); + 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(expected = ContainerLaunchException.class) - public void testEmptyConfigurationOverride() { + @Test + void testEmptyConfigurationOverride() { try ( CassandraContainer cassandraContainer = new CassandraContainer<>(CASSANDRA_IMAGE) .withConfigurationOverride("cassandra-empty-configuration") ) { - cassandraContainer.start(); + assertThatThrownBy(cassandraContainer::start).isInstanceOf(ContainerLaunchException.class); } } @Test - public void testInitScript() { + void testInitScript() { try ( CassandraContainer cassandraContainer = new CassandraContainer<>(CASSANDRA_IMAGE) .withInitScript("initial.cql") @@ -79,9 +83,11 @@ public void testInitScript() { } @Test - public void testInitScriptWithLegacyCassandra() { + void testInitScriptWithLegacyCassandra() { try ( - CassandraContainer cassandraContainer = new CassandraContainer<>(DockerImageName.parse("cassandra:2.2.11")) + CassandraContainer cassandraContainer = new CassandraContainer<>( + DockerImageName.parse("cassandra:2.2.11") + ) .withInitScript("initial.cql") ) { cassandraContainer.start(); @@ -91,38 +97,39 @@ public void testInitScriptWithLegacyCassandra() { @SuppressWarnings("deprecation") // Using deprecated constructor for verification of backwards compatibility @Test - public void testCassandraQueryWaitStrategy() { + void testCassandraQueryWaitStrategy() { try ( CassandraContainer cassandraContainer = new CassandraContainer<>() .waitingFor(new CassandraQueryWaitStrategy()) ) { cassandraContainer.start(); - ResultSet resultSet = performQuery(cassandraContainer, "SELECT release_version FROM system.local"); - assertTrue("Query was not applied", resultSet.wasApplied()); + 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 - public void testCassandraGetCluster() { + void testCassandraGetCluster() { try (CassandraContainer cassandraContainer = new CassandraContainer<>()) { cassandraContainer.start(); - ResultSet resultSet = performQuery(cassandraContainer.getCluster(), "SELECT release_version FROM system.local"); - assertTrue("Query was not applied", resultSet.wasApplied()); - assertNotNull("Result set has no release_version", resultSet.one().getString(0)); + 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"); - assertTrue("Query was not applied", resultSet.wasApplied()); + assertThat(resultSet.wasApplied()).as("Query was applied").isTrue(); Row row = resultSet.one(); - assertEquals("Inserted row is not in expected state", 1, row.getLong(0)); - assertEquals("Inserted row is not in expected state", "test_category", row.getString(1)); + 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() + Cluster explicitCluster = Cluster + .builder() .addContactPoint(cassandraContainer.getHost()) .withPort(cassandraContainer.getMappedPort(CassandraContainer.CQL_PORT)) .build(); 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 index 5b57b2a8e58..426dea64771 100644 --- a/modules/cassandra/src/test/resources/cassandra-test-configuration-example/cassandra.yaml +++ b/modules/cassandra/src/test/resources/cassandra-test-configuration-example/cassandra.yaml @@ -250,7 +250,7 @@ commit_failure_policy: stop # # 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 +# 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 @@ -259,7 +259,7 @@ commit_failure_policy: stop # 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. +# 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 @@ -309,7 +309,7 @@ key_cache_save_period: 14400 # Fully off-heap row cache implementation (default). # # org.apache.cassandra.cache.SerializingCacheProvider -# This is the row cache implementation availabile +# This is the row cache implementation available # in previous releases of Cassandra. # row_cache_class_name: org.apache.cassandra.cache.OHCProvider @@ -444,7 +444,7 @@ concurrent_counter_writes: 32 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 +# 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 @@ -553,7 +553,7 @@ memtable_allocation_type: heap_buffers # 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 +# 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 @@ -778,7 +778,7 @@ auto_snapshot: true # 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 +# 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 @@ -1109,7 +1109,7 @@ 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 +# 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). # @@ -1143,7 +1143,7 @@ transparent_data_encryption_options: # 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. +# 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 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/logback-test.xml b/modules/cassandra/src/test/resources/logback-test.xml index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/cassandra/src/test/resources/logback-test.xml +++ b/modules/cassandra/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + 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 index 323475b1d1c..59ea169583c 100644 --- a/modules/clickhouse/build.gradle +++ b/modules/clickhouse/build.gradle @@ -1,9 +1,16 @@ description = "Testcontainers :: JDBC :: ClickHouse" dependencies { - compile project(':testcontainers') - compile project(':jdbc') + api project(':testcontainers') + api project(':testcontainers-jdbc') - testCompile project(':jdbc-test') - testCompile 'ru.yandex.clickhouse:clickhouse-jdbc:0.2.4' + 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 index e26ca8c8416..e9f10feb746 100644 --- a/modules/clickhouse/src/main/java/org/testcontainers/containers/ClickHouseContainer.java +++ b/modules/clickhouse/src/main/java/org/testcontainers/containers/ClickHouseContainer.java @@ -1,15 +1,26 @@ 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 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(); @@ -17,18 +28,27 @@ public class ClickHouseContainer extends JdbcDatabaseContainer { 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 DRIVER_CLASS_NAME = "ru.yandex.clickhouse.ClickHouseDriver"; + 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 use {@link #ClickHouseContainer(DockerImageName)} instead */ @Deprecated public ClickHouseContainer() { @@ -41,26 +61,39 @@ public ClickHouseContainer(String dockerImageName) { public ClickHouseContainer(final DockerImageName dockerImageName) { super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, CLICKHOUSE_IMAGE_NAME); + supportsNewDriver = isNewDriverSupported(dockerImageName); - dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); - - withExposedPorts(HTTP_PORT, NATIVE_PORT); - waitingFor( + addExposedPorts(HTTP_PORT, NATIVE_PORT); + this.waitStrategy = new HttpWaitStrategy() .forStatusCode(200) - .forResponsePredicate(responseBody -> "Ok.".equals(responseBody)) - .withStartupTimeout(Duration.ofMinutes(1)) - ); + .forResponsePredicate("Ok."::equals) + .withStartupTimeout(Duration.ofMinutes(1)); } @Override - protected Integer getLivenessCheckPort() { - return getMappedPort(HTTP_PORT); + public Set getLivenessCheckPortNumbers() { + return new HashSet<>(getMappedPort(HTTP_PORT)); } @Override public String getDriverClassName() { - return DRIVER_CLASS_NAME; + 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 diff --git a/modules/clickhouse/src/main/java/org/testcontainers/containers/ClickHouseProvider.java b/modules/clickhouse/src/main/java/org/testcontainers/containers/ClickHouseProvider.java index cd2453b8349..250631c1500 100644 --- a/modules/clickhouse/src/main/java/org/testcontainers/containers/ClickHouseProvider.java +++ b/modules/clickhouse/src/main/java/org/testcontainers/containers/ClickHouseProvider.java @@ -1,15 +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(ClickHouseContainer.NAME); + return databaseType.equals("clickhouse"); + } + + @Override + public JdbcDatabaseContainer newInstance() { + return newInstance(DEFAULT_TAG); } @Override - public JdbcDatabaseContainer newInstance(String tag) { - return new ClickHouseContainer(DockerImageName.parse(ClickHouseContainer.IMAGE).withTag(tag)); + 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.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 index 961df045bd1..eff4e19f70b 100644 --- a/modules/clickhouse/src/test/java/org/testcontainers/ClickhouseTestImages.java +++ b/modules/clickhouse/src/test/java/org/testcontainers/ClickhouseTestImages.java @@ -3,5 +3,7 @@ import org.testcontainers.utility.DockerImageName; public interface ClickhouseTestImages { - DockerImageName CLICKHOUSE_IMAGE = DockerImageName.parse("yandex/clickhouse-server:18.10.3"); + 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 index f3946c8a317..0a85a26899a 100644 --- a/modules/clickhouse/src/test/java/org/testcontainers/jdbc/clickhouse/ClickhouseJDBCDriverTest.java +++ b/modules/clickhouse/src/test/java/org/testcontainers/jdbc/clickhouse/ClickhouseJDBCDriverTest.java @@ -1,21 +1,17 @@ package org.testcontainers.jdbc.clickhouse; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; import org.testcontainers.jdbc.AbstractJDBCDriverTest; +import java.util.Arrays; import java.util.EnumSet; -import static java.util.Arrays.asList; +class ClickhouseJDBCDriverTest extends AbstractJDBCDriverTest { -@RunWith(Parameterized.class) -public class ClickhouseJDBCDriverTest extends AbstractJDBCDriverTest { - - @Parameterized.Parameters(name = "{index} - {0}") public static Iterable data() { - return asList( - new Object[][]{ - {"jdbc:tc:clickhouse://hostname/databasename", EnumSet.of(Options.PmdKnownBroken)}, - }); + 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 index 8f3879673a0..c9bd9e917f5 100644 --- a/modules/clickhouse/src/test/java/org/testcontainers/junit/clickhouse/SimpleClickhouseTest.java +++ b/modules/clickhouse/src/test/java/org/testcontainers/junit/clickhouse/SimpleClickhouseTest.java @@ -1,26 +1,26 @@ package org.testcontainers.junit.clickhouse; -import org.junit.Test; +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.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.testcontainers.ClickhouseTestImages.CLICKHOUSE_IMAGE; +import static org.assertj.core.api.Assertions.assertThat; -public class SimpleClickhouseTest extends AbstractContainerDatabaseTest { +class SimpleClickhouseTest extends AbstractContainerDatabaseTest { @Test public void testSimple() throws SQLException { - try (ClickHouseContainer clickhouse = new ClickHouseContainer(CLICKHOUSE_IMAGE)) { + try (ClickHouseContainer clickhouse = new ClickHouseContainer(ClickhouseTestImages.CLICKHOUSE_IMAGE)) { clickhouse.start(); ResultSet resultSet = performQuery(clickhouse, "SELECT 1"); int resultSetInt = resultSet.getInt(1); - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); + 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 index a3927258679..275e69eef3d 100644 --- a/modules/cockroachdb/build.gradle +++ b/modules/cockroachdb/build.gradle @@ -1,8 +1,9 @@ description = "Testcontainers :: JDBC :: CockroachDB" dependencies { - compile project(':jdbc') + api project(':testcontainers-jdbc') - testCompile project(':jdbc-test') - testCompile 'org.postgresql:postgresql:42.2.12' + 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 index f32e6bce385..461b0467767 100644 --- a/modules/cockroachdb/src/main/java/org/testcontainers/containers/CockroachContainer.java +++ b/modules/cockroachdb/src/main/java/org/testcontainers/containers/CockroachContainer.java @@ -1,13 +1,30 @@ package org.testcontainers.containers; -import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +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"; @@ -19,17 +36,27 @@ public class CockroachContainer extends JdbcDatabaseContainer 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 index 75b1fadb47e..097aeef005b 100644 --- a/modules/cockroachdb/src/test/java/org/testcontainers/jdbc/cockroachdb/CockroachDBJDBCDriverTest.java +++ b/modules/cockroachdb/src/test/java/org/testcontainers/jdbc/cockroachdb/CockroachDBJDBCDriverTest.java @@ -1,21 +1,17 @@ package org.testcontainers.jdbc.cockroachdb; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; import org.testcontainers.jdbc.AbstractJDBCDriverTest; +import java.util.Arrays; import java.util.EnumSet; -import static java.util.Arrays.asList; +class CockroachDBJDBCDriverTest extends AbstractJDBCDriverTest { -@RunWith(Parameterized.class) -public class CockroachDBJDBCDriverTest extends AbstractJDBCDriverTest { - - @Parameterized.Parameters(name = "{index} - {0}") public static Iterable data() { - return asList( - new Object[][]{ - {"jdbc:tc:cockroach://hostname/databasename", EnumSet.noneOf(Options.class)}, - }); + return Arrays.asList( + new Object[][] { // + { "jdbc:tc:cockroach:v22.2.3://hostname/databasename", EnumSet.noneOf(Options.class) }, + } + ); } } diff --git a/modules/cockroachdb/src/test/java/org/testcontainers/junit/cockroachdb/SimpleCockroachDBTest.java b/modules/cockroachdb/src/test/java/org/testcontainers/junit/cockroachdb/SimpleCockroachDBTest.java deleted file mode 100644 index 3121ad82100..00000000000 --- a/modules/cockroachdb/src/test/java/org/testcontainers/junit/cockroachdb/SimpleCockroachDBTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package org.testcontainers.junit.cockroachdb; - -import org.junit.Test; -import org.testcontainers.containers.CockroachContainer; -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.hamcrest.CoreMatchers.containsString; -import static org.junit.Assert.assertThat; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.testcontainers.CockroachDBTestImages.COCKROACHDB_IMAGE; - -public class SimpleCockroachDBTest 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 - public void testSimple() throws SQLException { - try (CockroachContainer cockroach = new CockroachContainer(COCKROACHDB_IMAGE)) { - cockroach.start(); - - ResultSet resultSet = performQuery(cockroach, "SELECT 1"); - - int resultSetInt = resultSet.getInt(1); - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } - } - - @Test - public void testExplicitInitScript() throws SQLException { - try (CockroachContainer cockroach = new CockroachContainer(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); - assertEquals("Value from init script should equal real value", "hello world", firstColumnValue); - } - } - - @Test - public void testWithAdditionalUrlParamInJdbcUrl() { - CockroachContainer cockroach = new CockroachContainer(COCKROACHDB_IMAGE) - .withUrlParam("sslmode", "disable") - .withUrlParam("application_name", "cockroach"); - - try { - cockroach.start(); - String jdbcUrl = cockroach.getJdbcUrl(); - assertThat(jdbcUrl, containsString("?")); - assertThat(jdbcUrl, containsString("&")); - assertThat(jdbcUrl, containsString("sslmode=disable")); - assertThat(jdbcUrl, containsString("application_name=cockroach")); - } finally { - cockroach.stop(); - } - } -} 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/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 index db292de72f1..480841c2906 100644 --- a/modules/couchbase/AUTHORS +++ b/modules/couchbase/AUTHORS @@ -1 +1,2 @@ Tayeb Chlyah +Tobias Happ diff --git a/modules/couchbase/build.gradle b/modules/couchbase/build.gradle index 9cb778fd479..068e334e2f1 100644 --- a/modules/couchbase/build.gradle +++ b/modules/couchbase/build.gradle @@ -1,7 +1,10 @@ description = "Testcontainers :: Couchbase" dependencies { - compile project(':testcontainers') + api project(':testcontainers') + // TODO use JDK's HTTP client and/or Apache HttpClient5 + shaded 'com.squareup.okhttp3:okhttp:5.5.0' - testCompile 'com.couchbase.client:java-client:2.7.15' + 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 index 8c2b11403ad..c698855927c 100644 --- a/modules/couchbase/src/main/java/org/testcontainers/couchbase/BucketDefinition.java +++ b/modules/couchbase/src/main/java/org/testcontainers/couchbase/BucketDefinition.java @@ -22,22 +22,59 @@ 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; } /** - * Sets a custom bucket quota (100MB by default). + * 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 quota the quota to set for the bucket. + * @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!"); + throw new IllegalArgumentException("Bucket quota cannot be less than 100MB!"); } this.quota = quota; return this; @@ -58,6 +95,10 @@ public String getName() { return name; } + public boolean hasFlushEnabled() { + return flushEnabled; + } + public boolean hasPrimaryIndex() { return queryPrimaryIndex; } @@ -66,4 +107,7 @@ 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 index 3671608d324..14997d253af 100644 --- a/modules/couchbase/src/main/java/org/testcontainers/couchbase/CouchbaseContainer.java +++ b/modules/couchbase/src/main/java/org/testcontainers/couchbase/CouchbaseContainer.java @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package org.testcontainers.couchbase; import com.fasterxml.jackson.databind.JsonNode; @@ -36,8 +37,10 @@ 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; @@ -45,7 +48,14 @@ import java.util.stream.Collectors; /** - * The couchbase container initializes and configures a Couchbase Server single node cluster. + * 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. @@ -68,14 +78,20 @@ public class CouchbaseContainer extends GenericContainer { 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 String DEFAULT_TAG = "6.5.1"; - private static final ObjectMapper MAPPER = new ObjectMapper(); private static final OkHttpClient HTTP_CLIENT = new OkHttpClient(); @@ -84,18 +100,28 @@ public class CouchbaseContainer extends GenericContainer { private String password = "password"; - private Set enabledServices = EnumSet.allOf(CouchbaseService.class); - - private final List buckets = new ArrayList<>(); + /** + * 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 + ); /** - * Creates a new couchbase container with the default image and version. - * @deprecated use {@link CouchbaseContainer(DockerImageName)} instead + * Holds the custom service quotas if configured by the user. */ - @Deprecated - public CouchbaseContainer() { - this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); - } + 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. @@ -112,7 +138,6 @@ public CouchbaseContainer(final String dockerImageName) { */ public CouchbaseContainer(final DockerImageName dockerImageName) { super(dockerImageName); - dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); } @@ -142,6 +167,54 @@ public CouchbaseContainer withEnabledServices(final CouchbaseService... 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; } @@ -166,71 +239,190 @@ public String getConnectionString() { 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( + waitStrategy = + waitStrategy.withStrategy( new HttpWaitStrategy() - .forPath("/admin/ping") - .forPort(QUERY_PORT) + .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."); - waitUntilNodeIsOnline(); - renameNode(); - initializeServices(); - configureAdminUser(); - configureExternalPorts(); + 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)) { - configureIndexer(); + timePhase("configureIndexer", this::configureIndexer); + } + } + + @Override + protected void containerIsStarted(InspectContainerResponse containerInfo, boolean reused) { + if (!reused) { + this.containerIsStarted(containerInfo); } } @Override protected void containerIsStarted(InspectContainerResponse containerInfo) { - createBuckets(); - logger().info("Couchbase container is ready! UI available at http://{}:{}", getHost(), getMappedPort(MGMT_PORT)); + 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); + 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"); + } } /** @@ -242,9 +434,13 @@ private void waitUntilNodeIsOnline() { 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 + @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"); @@ -261,14 +457,46 @@ private void initializeServices() { .map(CouchbaseService::getIdentifier) .collect(Collectors.joining(",")); - @Cleanup Response response = doHttpRequest(MGMT_PORT, "/node/controller/setupServices", "POST", new FormBody.Builder() - .add("services", services) - .build(), false + @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. *

@@ -277,11 +505,18 @@ private void initializeServices() { 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); + @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"); } @@ -299,26 +534,49 @@ private void configureExternalPorts() { final FormBody.Builder builder = new FormBody.Builder(); builder.add("hostname", getHost()); builder.add("mgmt", Integer.toString(getMappedPort(MGMT_PORT))); - builder.add("mgmtSSL", Integer.toString(getMappedPort(MGMT_SSL_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("kvSSL", Integer.toString(getMappedPort(KV_SSL_PORT))); builder.add("capi", Integer.toString(getMappedPort(VIEW_PORT))); - builder.add("capiSSL", Integer.toString(getMappedPort(VIEW_SSL_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))); - builder.add("n1qlSSL", Integer.toString(getMappedPort(QUERY_SSL_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))); - builder.add("ftsSSL", Integer.toString(getMappedPort(SEARCH_SSL_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))); + } } - @Cleanup Response response = doHttpRequest( + 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", @@ -335,9 +593,13 @@ private void configureExternalPorts() { private void configureIndexer() { logger().debug("Configuring the indexer service"); - @Cleanup Response response = doHttpRequest(MGMT_PORT, "/settings/indexes", "POST", new FormBody.Builder() - .add("storageMode", "memory_optimized") - .build(), true + @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"); @@ -352,48 +614,147 @@ private void createBuckets() { 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())) - .build(), true); + @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()); - new HttpWaitStrategy() - .forPath("/pools/default/b/" + bucket.getName()) - .forPort(MGMT_PORT) - .withBasicCredentials(username, password) - .forStatusCode(200) - .forResponsePredicate(new AllServicesEnabledPredicate()) - .waitUntilReady(this); + 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. - 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); - }); + 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); + @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; + } + } - checkSuccessfulResponse(queryResponse, "Could not create primary index for bucket " + bucket.getName()); + 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()); + logger() + .info( + "Primary index creation for bucket {} ignored, since QUERY service is not present.", + bucket.getName() + ); } } } @@ -403,7 +764,11 @@ private void createBuckets() { * Helper method to extract the internal IP address based on the network configuration. */ private String getInternalIpAddress() { - return getContainerInfo().getNetworkSettings().getNetworks().values().stream() + return getContainerInfo() + .getNetworkSettings() + .getNetworks() + .values() + .stream() .findFirst() .map(ContainerNetwork::getIpAddress) .orElseThrow(() -> new IllegalStateException("No network available to extract the internal IP from!")); @@ -426,7 +791,7 @@ private void checkSuccessfulResponse(final Response response, final String messa } } - throw new IllegalStateException(message + ": " + response.toString() + ", body=" + (body == null ? "" : body)); + throw new IllegalStateException(message + ": " + response + ", body=" + (body == null ? "" : body)); } } @@ -449,8 +814,13 @@ private void checkNotRunning() { * @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) { + 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); @@ -471,6 +841,20 @@ private Response doHttpRequest(final int port, final String path, final String m } } + /** + * 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. diff --git a/modules/couchbase/src/main/java/org/testcontainers/couchbase/CouchbaseService.java b/modules/couchbase/src/main/java/org/testcontainers/couchbase/CouchbaseService.java index c60fc6b71d0..d9a9d2ffe3a 100644 --- a/modules/couchbase/src/main/java/org/testcontainers/couchbase/CouchbaseService.java +++ b/modules/couchbase/src/main/java/org/testcontainers/couchbase/CouchbaseService.java @@ -20,34 +20,71 @@ * Describes the different services that should be exposed on the container. */ public enum CouchbaseService { + /** + * Key-Value service. + */ + KV("kv", 256), - /** - * Key-Value service. - */ - KV("kv"), + /** + * Query (N1QL) service. + *

+ * Note that the query service has no memory quota, so it is set to 0. + */ + QUERY("n1ql", 0), - /** - * Query (N1QL) service. - */ - QUERY("n1ql"), + /** + * Search (FTS) service. + */ + SEARCH("fts", 256), - /** - * Search (FTS) service. - */ - SEARCH("fts"), + /** + * Indexing service (needed if QUERY is also used!). + */ + INDEX("index", 256), - /** - * Indexing service (needed if QUERY is also used!). - */ - INDEX("index"); + /** + * Analytics service. + */ + ANALYTICS("cbas", 1024), - private final String identifier; + /** + * Eventing service. + */ + EVENTING("eventing", 256); - CouchbaseService(String identifier) { - this.identifier = identifier; - } + private final String identifier; - String getIdentifier() { - return 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 index 3a4438c1fc6..b973d5eff2a 100644 --- a/modules/couchbase/src/test/java/org/testcontainers/couchbase/CouchbaseContainerTest.java +++ b/modules/couchbase/src/test/java/org/testcontainers/couchbase/CouchbaseContainerTest.java @@ -18,64 +18,151 @@ import com.couchbase.client.java.Bucket; import com.couchbase.client.java.Cluster; -import com.couchbase.client.java.CouchbaseCluster; -import com.couchbase.client.java.document.JsonDocument; -import com.couchbase.client.java.document.json.JsonObject; -import com.couchbase.client.java.env.CouchbaseEnvironment; -import com.couchbase.client.java.env.DefaultCouchbaseEnvironment; -import org.junit.Test; -import org.testcontainers.utility.DockerImageName; +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 static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; +import java.time.Duration; +import java.util.function.Consumer; -public class CouchbaseContainerTest { +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; - private static final DockerImageName COUCHBASE_IMAGE = DockerImageName.parse("couchbase/server:6.5.1"); +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 - public void testBasicContainerUsage() { + 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(COUCHBASE_IMAGE) - .withBucket(bucketDefinition) + CouchbaseContainer container = new CouchbaseContainer(couchbaseImage).withBucket(bucketDefinition) // } ) { - container.start(); - - // cluster_creation { - CouchbaseEnvironment environment = DefaultCouchbaseEnvironment - .builder() - .bootstrapCarrierDirectPort(container.getBootstrapCarrierDirectPort()) - .bootstrapHttpDirectPort(container.getBootstrapHttpDirectPort()) - .build(); - - Cluster cluster = CouchbaseCluster.create( - environment, - container.getHost() + 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"); + } ); - // } + } + } - try { - // auth { - cluster.authenticate(container.getUsername(), container.getPassword()); - // } + @Test + void testBucketIsFlushableIfEnabled() { + BucketDefinition bucketDefinition = new BucketDefinition("mybucket").withFlushEnabled(true); - Bucket bucket = cluster.openBucket(bucketDefinition.getName()); + 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()); - bucket.upsert(JsonDocument.create("foo", JsonObject.empty())); + await().untilAsserted(() -> assertThat(collection.exists("foo").exists()).isFalse()); + } + ); + } + } - assertTrue(bucket.exists("foo")); - assertNotNull(cluster.clusterManager().getBucket(bucketDefinition.getName())); - } finally { - cluster.disconnect(); - environment.shutdown(); - } + /** + * 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 index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/couchbase/src/test/resources/logback-test.xml +++ b/modules/couchbase/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + 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 index dacd8a500ac..345b354d739 100644 --- a/modules/database-commons/build.gradle +++ b/modules/database-commons/build.gradle @@ -1,7 +1,5 @@ description = "Testcontainers :: Database-Commons" dependencies { - compile project(':testcontainers') - - testCompile 'org.assertj:assertj-core:3.18.1' + 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 index 14572dec421..a84535ed5fd 100644 --- a/modules/database-commons/src/main/java/org/testcontainers/delegate/AbstractDatabaseDelegate.java +++ b/modules/database-commons/src/main/java/org/testcontainers/delegate/AbstractDatabaseDelegate.java @@ -4,7 +4,6 @@ /** * @param connection to the database - * @author Eugeny Karpov */ public abstract class AbstractDatabaseDelegate implements DatabaseDelegate { @@ -27,7 +26,12 @@ protected CONNECTION getConnection() { } @Override - public void execute(Collection statements, String scriptPath, boolean continueOnError, boolean ignoreFailedDrops) { + public void execute( + Collection statements, + String scriptPath, + boolean continueOnError, + boolean ignoreFailedDrops + ) { int lineNumber = 0; for (String statement : statements) { lineNumber++; 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 index 55886d88143..e9dd207e112 100644 --- a/modules/database-commons/src/main/java/org/testcontainers/delegate/DatabaseDelegate.java +++ b/modules/database-commons/src/main/java/org/testcontainers/delegate/DatabaseDelegate.java @@ -6,15 +6,18 @@ * Database delegate * * Gives an abstraction from concrete database - * - * @author Eugeny Karpov */ 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); + void execute( + String statement, + String scriptPath, + int lineNumber, + boolean continueOnError, + boolean ignoreFailedDrops + ); /** * Execute collection of statements 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 index 3b8852dbddc..0a117ac23d2 100644 --- a/modules/database-commons/src/main/java/org/testcontainers/exception/ConnectionCreationException.java +++ b/modules/database-commons/src/main/java/org/testcontainers/exception/ConnectionCreationException.java @@ -2,8 +2,6 @@ /** * Inability to create connection to the database - * - * @author Eugeny Karpov */ public class ConnectionCreationException extends RuntimeException { 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 index d30064af28e..b78ac4d63c4 100644 --- a/modules/database-commons/src/main/java/org/testcontainers/ext/ScriptUtils.java +++ b/modules/database-commons/src/main/java/org/testcontainers/ext/ScriptUtils.java @@ -17,17 +17,19 @@ package org.testcontainers.ext; import org.apache.commons.io.IOUtils; -import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.testcontainers.delegate.DatabaseDelegate; -import javax.script.ScriptException; 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 @@ -35,288 +37,208 @@ * * Generic utility methods for working with SQL scripts. Mainly for internal use * within the framework. - * - * @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 */ 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"; + private static final Logger LOGGER = LoggerFactory.getLogger(ScriptUtils.class); - /** - * Default prefix for line comments within SQL scripts. - */ - public static final String DEFAULT_COMMENT_PREFIX = "--"; + /** + * Default statement separator within SQL scripts. + */ + public static final String DEFAULT_STATEMENT_SEPARATOR = ";"; - /** - * Default start delimiter for block comments within SQL scripts. - */ - public static final String DEFAULT_BLOCK_COMMENT_START_DELIMITER = "/*"; + /** + * 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 end delimiter for block comments within SQL scripts. - */ - public static final String DEFAULT_BLOCK_COMMENT_END_DELIMITER = "*/"; + /** + * 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 = "/*"; - /** - * Prevent instantiation of this utility class. - */ - private ScriptUtils() { - /* no-op */ - } + /** + * Default end delimiter for block comments within SQL scripts. + */ + public static final String DEFAULT_BLOCK_COMMENT_END_DELIMITER = "*/"; /** - * 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"); - - StringBuilder sb = new StringBuilder(); - boolean inEscape = false; - boolean inLineComment = false; - boolean inBlockComment = false; - Character currentLiteralDelimiter = null; - - int compoundStatementDepth = 0; - final String lowerCaseScriptContent = script.toLowerCase(); - 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; - } - // Determine whether we're entering/leaving a string literal - if (!inBlockComment && !inLineComment && (c == '\'' || c == '"' || c == '`')) { - if (currentLiteralDelimiter == null) { // ignore delimiters within an existing string literal - currentLiteralDelimiter = c; - } else if (currentLiteralDelimiter == c) { // find end of string literal - currentLiteralDelimiter = null; - } - } - final boolean inLiteral = currentLiteralDelimiter != null; - - if (!inLiteral && containsSubstringAtOffset(lowerCaseScriptContent, commentPrefix, i)) { - inLineComment = true; - } - if (inLineComment && c == '\n') { - inLineComment = false; - } - if (!inLiteral && containsSubstringAtOffset(lowerCaseScriptContent, blockCommentStartDelimiter, i)) { - inBlockComment = true; - } - if (!inLiteral && inBlockComment && containsSubstringAtOffset(lowerCaseScriptContent, blockCommentEndDelimiter, i)) { - inBlockComment = false; - } - final boolean inComment = inLineComment || inBlockComment; - - if (!inLiteral && !inComment && containsKeywordsAtOffset(lowerCaseScriptContent, "BEGIN", i, separator, commentPrefix, blockCommentStartDelimiter)) { - compoundStatementDepth++; - } - if (!inLiteral && !inComment && containsKeywordsAtOffset(lowerCaseScriptContent, "END", i, separator, commentPrefix, blockCommentStartDelimiter)) { - compoundStatementDepth--; - } - final boolean inCompoundStatement = compoundStatementDepth != 0; - - if (!inLiteral && !inCompoundStatement) { - if (script.startsWith(separator, i)) { - // we've reached the end of the current statement - sb = flushStringBuilder(sb, statements); - 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; - inBlockComment = false; - continue; - } - else { - throw new ScriptParseException(String.format("Missing block comment end delimiter [%s].", - blockCommentEndDelimiter), resource); - } - } - else if (c == ' ' || c == '\n' || c == '\t' || c == '\r') { - // avoid multiple adjacent whitespace characters - if (sb.length() > 0 && sb.charAt(sb.length() - 1) != ' ') { - c = ' '; - } - else { - continue; - } - } - } - sb.append(c); - } - flushStringBuilder(sb, statements); - } - - private static StringBuilder flushStringBuilder(StringBuilder sb, List statements) { - if (sb.length() == 0) { - return sb; - } - - final String s = sb.toString().trim(); - if (StringUtils.isNotEmpty(s)) { - statements.add(s); - } - - return new StringBuilder(); - } - - private static boolean isSeperator(char c, String separator, String commentPrefix, - String blockCommentStartDelimiter) { - return c == ' ' || c == '\r' || c == '\n' || c == '\t' || - c == separator.charAt(0) || c == separator.charAt(separator.length() - 1) || - c == commentPrefix.charAt(0) || c == blockCommentStartDelimiter.charAt(0) || - c == blockCommentStartDelimiter.charAt(blockCommentStartDelimiter.length() - 1); + * Prevent instantiation of this utility class. + */ + private ScriptUtils() { + /* no-op */ } - private static boolean containsSubstringAtOffset(String lowercaseString, String substring, int offset) { - String lowercaseSubstring = substring.toLowerCase(); + /** + * 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(); + } - return lowercaseString.startsWith(lowercaseSubstring, offset); - } + private static void checkArgument(boolean expression, String errorMessage) { + if (!expression) { + throw new IllegalArgumentException(errorMessage); + } + } - private static boolean containsKeywordsAtOffset(String lowercaseString, String keywords, int offset, - String separator, String commentPrefix, - String blockCommentStartDelimiter) { - String lowercaseKeywords = keywords.toLowerCase(); + /** + * 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 + ); + } - boolean backSeperated = (offset == 0) || isSeperator(lowercaseString.charAt(offset - 1), - separator, commentPrefix, blockCommentStartDelimiter); - boolean frontSeperated = (offset >= (lowercaseString.length() - keywords.length())) || - isSeperator(lowercaseString.charAt(offset + keywords.length()), - separator, commentPrefix, blockCommentStartDelimiter); + /** + * 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; + } - return backSeperated && frontSeperated && lowercaseString.startsWith(lowercaseKeywords, offset); + /** + * 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); + } } - 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) { - 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; - } - - /** - * 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 = 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); + 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. + * 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) @@ -325,85 +247,113 @@ public static void executeDatabaseScript(DatabaseDelegate databaseDelegate, Stri *@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 + * an error on a {@code DROP} statement * @param commentPrefix the prefix that identifies comments in the SQL script — -* typically "--" + * 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 + * {@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 + * {@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.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); - - try (DatabaseDelegate closeableDelegate = databaseDelegate) { - closeableDelegate.execute(statements, scriptPath, continueOnError, ignoreFailedDrops); - } - - long elapsedTime = System.currentTimeMillis() - 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); - } - } + * {@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 index 202a3b995db..cb0e33162f4 100644 --- a/modules/database-commons/src/test/java/org/testcontainers/ext/ScriptSplittingTest.java +++ b/modules/database-commons/src/test/java/org/testcontainers/ext/ScriptSplittingTest.java @@ -1,33 +1,30 @@ package org.testcontainers.ext; -import org.assertj.core.api.Assertions; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; -import static java.util.Arrays.asList; -import static org.junit.Assert.fail; -import static org.testcontainers.ext.ScriptUtils.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; -public class ScriptSplittingTest { +class ScriptSplittingTest { @Test - public void testStringDemarcation() { + void testStringDemarcation() { String script = "SELECT 'foo `bar`'; SELECT 'foo -- `bar`'; SELECT 'foo /* `bar`';"; - List expected = asList( - "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 - public void testIssue1547Case1() { - String script = "create database if not exists ttt;\n" + + void testIssue1547Case1() { + String script = + "create database if not exists ttt;\n" + "\n" + "use ttt;\n" + "\n" + @@ -43,7 +40,7 @@ public void testIssue1547Case1() { " id bigint auto_increment primary key\n" + ") comment 'bbb';"; - List expected = asList( + 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'", @@ -54,15 +51,16 @@ public void testIssue1547Case1() { } @Test - public void testIssue1547Case2() { - String script = "CREATE TABLE bar (\n" + + 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 = asList( + List expected = Arrays.asList( "CREATE TABLE bar ( end_time VARCHAR(255) )", "CREATE TABLE bar ( end_time VARCHAR(255) )" ); @@ -71,28 +69,29 @@ public void testIssue1547Case2() { } @Test - public void testUnusualSemicolonPlacement() { + 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 = asList( - "SELECT 1", - "SELECT 2", - "SELECT 3", - "SELECT 4", - "SELECT 5" - ); + List expected = Arrays.asList("SELECT 1", "SELECT 2", "SELECT 3", "SELECT 4", "SELECT 5"); splitAndCompare(script, expected); } @Test - public void testCommentedSemicolon() { - String script = "CREATE TABLE bar (\n" + - " foo VARCHAR(255)\n" + - "); \nDROP PROCEDURE IF EXISTS -- ;\n" + - " count_foo"; + void testCommentedSemicolon() { + String script = + "CREATE TABLE bar (\n" + " foo VARCHAR(255)\n" + "); \nDROP PROCEDURE IF EXISTS -- ;\n" + " count_foo"; - List expected = asList( + List expected = Arrays.asList( "CREATE TABLE bar ( foo VARCHAR(255) )", "DROP PROCEDURE IF EXISTS count_foo" ); @@ -101,12 +100,13 @@ public void testCommentedSemicolon() { } @Test - public void testStringEscaping() { - String script = "SELECT \"a /* string literal containing comment characters like -- here\";\n" + + 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 = asList( + List expected = Arrays.asList( "SELECT \"a /* string literal containing comment characters like -- here\"", "SELECT \"a 'quoting' \\\"scenario ` involving BEGIN keyword\\\" here\"", "SELECT * from `bar`" @@ -116,21 +116,20 @@ public void testStringEscaping() { } @Test - public void testBlockCommentExclusion() { + void testBlockCommentExclusion() { String script = "INSERT INTO bar (foo) /* ; */ VALUES ('hello world');"; - List expected = asList( - "INSERT INTO bar (foo) VALUES ('hello world')" - ); + List expected = Arrays.asList("INSERT INTO bar (foo) VALUES ('hello world')"); splitAndCompare(script, expected); } @Test - public void testBeginEndKeywordCorrectDetection() { - String script = "INSERT INTO something_end (begin_with_the_token, another_field) /*end*/ VALUES /* end */ (' begin ', `end`)-- begin\n;"; + void testBeginEndKeywordCorrectDetection() { + String script = + "INSERT INTO something_end (begin_with_the_token, another_field) /*end*/ VALUES /* end */ (' begin ', `end`)-- begin\n;"; - List expected = asList( + List expected = Arrays.asList( "INSERT INTO something_end (begin_with_the_token, another_field) VALUES (' begin ', `end`)" ); @@ -138,17 +137,18 @@ public void testBeginEndKeywordCorrectDetection() { } @Test - public void testCommentInStrings() { - String script = "CREATE TABLE bar (foo VARCHAR(255));\n" + + 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 (' */'); -- '*/;\n" + // purposefully broken, to see if it breaks our splitting "INSERT INTO bar (foo) values ('foo');"; - List expected = asList( + List expected = Arrays.asList( "CREATE TABLE bar (foo VARCHAR(255))", "INSERT INTO bar (foo) values ('--1')", "INSERT INTO bar (foo) values ('--2')", @@ -161,8 +161,9 @@ public void testCommentInStrings() { } @Test - public void testMultipleBeginEndDetection() { - String script = "CREATE TABLE bar (foo VARCHAR(255));\n" + + 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" + @@ -187,24 +188,16 @@ public void testMultipleBeginEndDetection() { "\n" + "CREATE TABLE foo (bar VARCHAR(255));"; - List expected = asList( + 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", + "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))" ); @@ -212,8 +205,9 @@ public void testMultipleBeginEndDetection() { } @Test - public void testProcedureBlock() { - String script = "CREATE PROCEDURE count_foo()\n" + + void testProcedureBlock() { + String script = + "CREATE PROCEDURE count_foo()\n" + " BEGIN\n" + "\n" + " BEGIN\n" + @@ -239,56 +233,53 @@ public void testProcedureBlock() { "\n" + " END /*; */;"; - List expected = asList( + 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" + "\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 - public void testUnclosedBlockComment() { + void testUnclosedBlockComment() { String script = "SELECT 'foo `bar`'; /*"; - - try { - doSplit(script); - fail("Should have thrown!"); - } catch (ScriptUtils.ScriptParseException expected) { - // ignore expected exception - } + assertThatThrownBy(() -> doSplit(script, ScriptUtils.DEFAULT_STATEMENT_SEPARATOR)) + .isInstanceOf(ScriptUtils.ScriptParseException.class) + .hasMessageContaining("*/"); } @Test - public void testIssue1452Case() { - String script = "create table test (text VARCHAR(255));\n" + + void testIssue1452Case() { + String script = + "create table test (text VARCHAR(255));\n" + "\n" + "/* some comment */\n" + "insert into `test` (`text`) values ('a b');"; - List expected = asList( + List expected = Arrays.asList( "create table test (text VARCHAR(255))", "insert into `test` (`text`) values ('a b')" ); @@ -296,14 +287,195 @@ public void testIssue1452Case() { 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) { - final List statements = doSplit(script); - Assertions.assertThat(statements).isEqualTo(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) { + private List doSplit(String script, String separator) { final List statements = new ArrayList<>(); - ScriptUtils.splitSqlScript("ignored", script, DEFAULT_STATEMENT_SEPARATOR, DEFAULT_COMMENT_PREFIX, DEFAULT_BLOCK_COMMENT_START_DELIMITER, DEFAULT_BLOCK_COMMENT_END_DELIMITER, statements); + 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 index feaf5f790c5..afae0bfd751 100644 --- a/modules/db2/build.gradle +++ b/modules/db2/build.gradle @@ -1,8 +1,8 @@ description = "Testcontainers :: JDBC :: DB2" dependencies { - compile project(':jdbc') + api project(':testcontainers-jdbc') - testCompile project(':jdbc-test') - testCompile 'com.ibm.db2:jcc:11.5.0.0' + 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 index c1a252936ea..19dbc6a58ac 100644 --- a/modules/db2/src/main/java/org/testcontainers/containers/Db2Container.java +++ b/modules/db2/src/main/java/org/testcontainers/containers/Db2Container.java @@ -1,33 +1,51 @@ 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.HashSet; 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 use {@link #Db2Container(DockerImageName)} instead */ @Deprecated public Db2Container() { @@ -40,25 +58,30 @@ public Db2Container(String dockerImageName) { public Db2Container(final DockerImageName dockerImageName) { super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_NEW_IMAGE_NAME, DEFAULT_IMAGE_NAME); - dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); - - withPrivilegedMode(true); - this.waitStrategy = new LogMessageWaitStrategy() + 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 new HashSet<>(getMappedPort(DB2_PORT)); + return super.getLivenessCheckPorts(); } @Override protected void configure() { - // If license was not accepted programatically, check if it was accepted via resource file + // If license was not accepted programmatically, check if it was accepted via resource file if (!getEnvMap().containsKey("LICENSE")) { LicenseAcceptance.assertLicenseAccepted(this.getDockerImageName()); acceptLicense(); @@ -69,10 +92,12 @@ protected void configure() { addEnv("DB2INST1_PASSWORD", password); // These settings help the DB2 container start faster - if (!getEnvMap().containsKey("AUTOCONFIG")) + if (!getEnvMap().containsKey("AUTOCONFIG")) { addEnv("AUTOCONFIG", "false"); - if (!getEnvMap().containsKey("ARCHIVE_LOGS")) + } + if (!getEnvMap().containsKey("ARCHIVE_LOGS")) { addEnv("ARCHIVE_LOGS", "false"); + } } /** @@ -92,8 +117,7 @@ public String getDriverClassName() { @Override public String getJdbcUrl() { String additionalUrlParams = constructUrlParameters(":", ";", ";"); - return "jdbc:db2://" + getHost() + ":" + getMappedPort(DB2_PORT) + - "/" + databaseName + additionalUrlParams; + return "jdbc:db2://" + getHost() + ":" + getMappedPort(DB2_PORT) + "/" + databaseName + additionalUrlParams; } @Override diff --git a/modules/db2/src/main/java/org/testcontainers/containers/Db2ContainerProvider.java b/modules/db2/src/main/java/org/testcontainers/containers/Db2ContainerProvider.java index 618b97c3ca6..42ac21f271b 100644 --- a/modules/db2/src/main/java/org/testcontainers/containers/Db2ContainerProvider.java +++ b/modules/db2/src/main/java/org/testcontainers/containers/Db2ContainerProvider.java @@ -3,6 +3,7 @@ import org.testcontainers.utility.DockerImageName; public class Db2ContainerProvider extends JdbcDatabaseContainerProvider { + @Override public boolean supports(String databaseType) { return databaseType.equals(Db2Container.NAME); 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/test/java/org/testcontainers/Db2TestImages.java b/modules/db2/src/test/java/org/testcontainers/Db2TestImages.java index b0bfc869272..002d030b1c5 100644 --- a/modules/db2/src/test/java/org/testcontainers/Db2TestImages.java +++ b/modules/db2/src/test/java/org/testcontainers/Db2TestImages.java @@ -3,5 +3,5 @@ import org.testcontainers.utility.DockerImageName; public interface Db2TestImages { - DockerImageName DB2_IMAGE = DockerImageName.parse("ibmcom/db2:11.5.0.0a"); + 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 index 2c87a4a1a65..cfa74c8017a 100644 --- a/modules/db2/src/test/java/org/testcontainers/jdbc/db2/DB2JDBCDriverTest.java +++ b/modules/db2/src/test/java/org/testcontainers/jdbc/db2/DB2JDBCDriverTest.java @@ -1,21 +1,17 @@ package org.testcontainers.jdbc.db2; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; import org.testcontainers.jdbc.AbstractJDBCDriverTest; +import java.util.Arrays; import java.util.EnumSet; -import static java.util.Arrays.asList; +class DB2JDBCDriverTest extends AbstractJDBCDriverTest { -@RunWith(Parameterized.class) -public class DB2JDBCDriverTest extends AbstractJDBCDriverTest { - - @Parameterized.Parameters(name = "{index} - {0}") public static Iterable data() { - return asList( - new Object[][]{ - {"jdbc:tc:db2://hostname/databasename", EnumSet.noneOf(Options.class)} - }); + return Arrays.asList( + new Object[][] { // + { "jdbc:tc:db2://hostname/databasename", EnumSet.noneOf(Options.class) }, + } + ); } } diff --git a/modules/db2/src/test/java/org/testcontainers/junit/db2/SimpleDb2Test.java b/modules/db2/src/test/java/org/testcontainers/junit/db2/SimpleDb2Test.java deleted file mode 100644 index b7a1d5478e8..00000000000 --- a/modules/db2/src/test/java/org/testcontainers/junit/db2/SimpleDb2Test.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.testcontainers.junit.db2; - -import org.junit.Test; -import org.testcontainers.containers.Db2Container; -import org.testcontainers.db.AbstractContainerDatabaseTest; - -import java.sql.ResultSet; -import java.sql.SQLException; - -import static org.hamcrest.CoreMatchers.containsString; -import static org.junit.Assert.assertThat; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.testcontainers.Db2TestImages.DB2_IMAGE; - -public class SimpleDb2Test extends AbstractContainerDatabaseTest { - - @Test - public void testSimple() throws SQLException { - try (Db2Container db2 = new Db2Container(DB2_IMAGE) - .acceptLicense()) { - - db2.start(); - - ResultSet resultSet = performQuery(db2, "SELECT 1 FROM SYSIBM.SYSDUMMY1"); - - int resultSetInt = resultSet.getInt(1); - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } - } - - @Test - public void testWithAdditionalUrlParamInJdbcUrl() { - try (Db2Container db2 = new Db2Container(DB2_IMAGE) - .withUrlParam("sslConnection", "false") - .acceptLicense()) { - - db2.start(); - - String jdbcUrl = db2.getJdbcUrl(); - assertThat(jdbcUrl, containsString(":sslConnection=false;")); - } - } -} 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/dynalite/build.gradle b/modules/dynalite/build.gradle deleted file mode 100644 index a09ef417159..00000000000 --- a/modules/dynalite/build.gradle +++ /dev/null @@ -1,8 +0,0 @@ -description = "Testcontainers :: Dynalite" - -dependencies { - compile project(':testcontainers') - - compileOnly 'com.amazonaws:aws-java-sdk-dynamodb:1.11.929' - testCompile 'com.amazonaws:aws-java-sdk-dynamodb:1.11.929' -} diff --git a/modules/dynalite/src/main/java/org/testcontainers/dynamodb/DynaliteContainer.java b/modules/dynalite/src/main/java/org/testcontainers/dynamodb/DynaliteContainer.java deleted file mode 100644 index 8900964a8bb..00000000000 --- a/modules/dynalite/src/main/java/org/testcontainers/dynamodb/DynaliteContainer.java +++ /dev/null @@ -1,77 +0,0 @@ -package org.testcontainers.dynamodb; - -import com.amazonaws.auth.AWSCredentialsProvider; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.client.builder.AwsClientBuilder; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.utility.DockerImageName; - -/** - * Container for Dynalite, a DynamoDB clone. - */ -public class DynaliteContainer extends GenericContainer { - - private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("quay.io/testcontainers/dynalite"); - private static final String DEFAULT_TAG = "v1.2.1-1"; - private static final int MAPPED_PORT = 4567; - - /** - * @deprecated use {@link DynaliteContainer(DockerImageName)} instead - */ - @Deprecated - public DynaliteContainer() { - this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); - } - - public DynaliteContainer(String dockerImageName) { - this(DockerImageName.parse(dockerImageName)); - } - - public DynaliteContainer(final DockerImageName dockerImageName) { - super(dockerImageName); - - dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); - - withExposedPorts(MAPPED_PORT); - } - - - /** - * Gets a preconfigured {@link AmazonDynamoDB} client object for connecting to this - * container. - * - * @return preconfigured client - */ - public AmazonDynamoDB getClient() { - return AmazonDynamoDBClientBuilder.standard() - .withEndpointConfiguration(getEndpointConfiguration()) - .withCredentials(getCredentials()) - .build(); - } - - /** - * Gets {@link AwsClientBuilder.EndpointConfiguration} - * that may be used to connect to this container. - * - * @return endpoint configuration - */ - public AwsClientBuilder.EndpointConfiguration getEndpointConfiguration() { - return new AwsClientBuilder.EndpointConfiguration("http://" + - this.getHost() + ":" + - this.getMappedPort(MAPPED_PORT), null); - } - - /** - * Gets an {@link AWSCredentialsProvider} that may be used to connect to this container. - * - * @return dummy AWS credentials - */ - public AWSCredentialsProvider getCredentials() { - return new AWSStaticCredentialsProvider(new BasicAWSCredentials("dummy", "dummy")); - } - - -} diff --git a/modules/dynalite/src/test/java/org/testcontainers/dynamodb/DynaliteContainerTest.java b/modules/dynalite/src/test/java/org/testcontainers/dynamodb/DynaliteContainerTest.java deleted file mode 100644 index 0b440939139..00000000000 --- a/modules/dynalite/src/test/java/org/testcontainers/dynamodb/DynaliteContainerTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package org.testcontainers.dynamodb; - -import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder; -import com.amazonaws.services.dynamodbv2.model.AttributeDefinition; -import com.amazonaws.services.dynamodbv2.model.CreateTableRequest; -import com.amazonaws.services.dynamodbv2.model.KeySchemaElement; -import com.amazonaws.services.dynamodbv2.model.KeyType; -import com.amazonaws.services.dynamodbv2.model.ProvisionedThroughput; -import com.amazonaws.services.dynamodbv2.model.ScalarAttributeType; -import com.amazonaws.services.dynamodbv2.model.TableDescription; -import org.junit.Rule; -import org.junit.Test; -import org.testcontainers.utility.DockerImageName; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertNotNull; - -public class DynaliteContainerTest { - - private static final DockerImageName DYNALITE_IMAGE = DockerImageName.parse("quay.io/testcontainers/dynalite:v1.2.1-1"); - - @Rule - public DynaliteContainer dynamoDB = new DynaliteContainer(DYNALITE_IMAGE); - - @Test - public void simpleTestWithManualClientCreation() { - final AmazonDynamoDB client = AmazonDynamoDBClientBuilder.standard() - .withEndpointConfiguration(dynamoDB.getEndpointConfiguration()) - .withCredentials(dynamoDB.getCredentials()) - .build(); - - runTest(client); - } - - @Test - public void simpleTestWithProvidedClient() { - final AmazonDynamoDB client = dynamoDB.getClient(); - - runTest(client); - } - - private void runTest(AmazonDynamoDB client) { - CreateTableRequest request = new CreateTableRequest() - .withAttributeDefinitions(new AttributeDefinition( - "Name", ScalarAttributeType.S)) - .withKeySchema(new KeySchemaElement("Name", KeyType.HASH)) - .withProvisionedThroughput(new ProvisionedThroughput( - 10L, 10L)) - .withTableName("foo"); - - - client.createTable(request); - - final TableDescription tableDescription = client.describeTable("foo").getTable(); - - assertNotNull("the description is not null", tableDescription); - assertEquals("the table has the right name", "foo", tableDescription.getTableName()); - assertEquals("the name has the right primary key", "Name", tableDescription.getKeySchema().get(0).getAttributeName()); - } -} diff --git a/modules/elasticsearch/build.gradle b/modules/elasticsearch/build.gradle index b4a64a405b7..7f57916e991 100644 --- a/modules/elasticsearch/build.gradle +++ b/modules/elasticsearch/build.gradle @@ -1,7 +1,8 @@ -description = "TestContainers :: elasticsearch" +description = "Testcontainers :: elasticsearch" dependencies { - compile project(':testcontainers') - testCompile "org.elasticsearch.client:elasticsearch-rest-client:7.10.1" - testCompile "org.elasticsearch.client:transport:7.10.1" + 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 index acbca15aaf5..2080d21649a 100644 --- a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java +++ b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java @@ -1,21 +1,44 @@ package org.testcontainers.elasticsearch; -import static java.net.HttpURLConnection.HTTP_OK; -import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; - -import java.net.InetSocketAddress; -import java.time.Duration; +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.HttpWaitStrategy; -import org.testcontainers.utility.Base58; +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; + /** - * Represents an elasticsearch docker instance which exposes by default port 9200 and 9300 (transport.tcp.port) - * The docker image is by default fetched from docker.elastic.co/elasticsearch/elasticsearch + * 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 */ @@ -31,26 +54,30 @@ public class ElasticsearchContainer extends GenericContainer= 8 + private static final String DEFAULT_CERT_PATH = "/usr/share/elasticsearch/config/certs/http_ca.crt"; - /** - * @deprecated use {@link ElasticsearchContainer(DockerImageName)} instead - */ @Deprecated - public ElasticsearchContainer() { - this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); - } + 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) { @@ -59,48 +86,224 @@ public ElasticsearchContainer(String 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); + 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() + ); } - logger().info("Starting an elasticsearch container using [{}]", dockerImageName); - withNetworkAliases("elasticsearch-" + Base58.randomString(6)); 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); - setWaitStrategy(new HttpWaitStrategy() - .forPort(ELASTICSEARCH_DEFAULT_PORT) - .forStatusCodeMatching(response -> response == HTTP_OK || response == HTTP_UNAUTHORIZED) - .withStartupTimeout(Duration.ofMinutes(2))); + 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. + * 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 + * @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"); + throw new IllegalArgumentException( + "You can not activate security on Elastic OSS Image. Please switch to the default distribution" + ); } withEnv("ELASTIC_PASSWORD", password); - withEnv("xpack.security.enabled", "true"); + 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); } - @Deprecated // The TransportClient will be removed in Elasticsearch 8. No need to expose this port anymore in the future. + /** + * 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 index 859587645d7..52cd78b4a34 100644 --- a/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java +++ b/modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java @@ -1,11 +1,6 @@ package org.testcontainers.elasticsearch; -import static org.hamcrest.CoreMatchers.containsString; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThrows; - -import java.io.IOException; +import com.github.dockerjava.api.DockerClient; import org.apache.http.HttpHost; import org.apache.http.auth.AuthScope; import org.apache.http.auth.UsernamePasswordCredentials; @@ -21,20 +16,34 @@ import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.transport.TransportAddress; import org.elasticsearch.transport.client.PreBuiltTransportClient; -import org.junit.After; -import org.junit.Test; +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; -public class ElasticsearchContainerTest { +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); + + private static final DockerImageName ELASTICSEARCH_IMAGE = DockerImageName + .parse("docker.elastic.co/elasticsearch/elasticsearch") + .withTag(ELASTICSEARCH_VERSION); /** * Elasticsearch default username, when secured @@ -47,9 +56,10 @@ public class ElasticsearchContainerTest { private static final String ELASTICSEARCH_PASSWORD = "changeme"; private RestClient client = null; + private RestClient anonymousClient = null; - @After + @AfterEach public void stopRestClient() throws IOException { if (client != null) { client.close(); @@ -64,102 +74,116 @@ public void stopRestClient() throws IOException { @SuppressWarnings("deprecation") // Using deprecated constructor for verification of backwards compatibility @Test @Deprecated // We will remove this test in the future - public void elasticsearchDeprecatedCtorTest() throws IOException { + void elasticsearchDeprecatedCtorTest() throws IOException { // Create the elasticsearch container. - try (ElasticsearchContainer container = new ElasticsearchContainer() - .withEnv("foo", "bar") // dummy env for compiler checking correct generics usage + 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(), is(200)); - assertThat(EntityUtils.toString(response.getEntity()), containsString(ELASTICSEARCH_VERSION)); + 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(), is(200)); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); // For now we test that we have the monitoring feature available - assertThat(EntityUtils.toString(response.getEntity()), containsString("monitoring")); + assertThat(EntityUtils.toString(response.getEntity())).contains("monitoring"); } } @Test - public void elasticsearchDefaultTest() throws IOException { + 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 + 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(), is(200)); - assertThat(EntityUtils.toString(response.getEntity()), containsString(ELASTICSEARCH_VERSION)); + 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(), is(200)); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); // For now we test that we have the monitoring feature available - assertThat(EntityUtils.toString(response.getEntity()), containsString("monitoring")); + assertThat(EntityUtils.toString(response.getEntity())).contains("monitoring"); } } @Test - public void elasticsearchSecuredTest() throws IOException { - try (ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE) - .withPassword(ELASTICSEARCH_PASSWORD)) { + 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 - assertThrows("We should not be able to access / URI with an anonymous client.", - ResponseException.class, - () -> getAnonymousClient(container).performRequest(new Request("GET", "/"))); + 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(), is(200)); - assertThat(EntityUtils.toString(response.getEntity()), containsString(ELASTICSEARCH_VERSION)); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + assertThat(EntityUtils.toString(response.getEntity())).contains(ELASTICSEARCH_VERSION); } } @Test - public void elasticsearchVersion() throws IOException { + 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(), is(200)); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); String responseAsString = EntityUtils.toString(response.getEntity()); - assertThat(responseAsString, containsString(ELASTICSEARCH_VERSION)); + assertThat(responseAsString).contains(ELASTICSEARCH_VERSION); } } @Test - public void elasticsearchOssImage() throws IOException { - try (ElasticsearchContainer container = - // ossContainer { - new ElasticsearchContainer( - DockerImageName - .parse("docker.elastic.co/elasticsearch/elasticsearch-oss") - .withTag(ELASTICSEARCH_VERSION) - ) - // } + 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(), is(200)); + 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 - assertThrows("We should not have /_xpack endpoint with an OSS License", - ResponseException.class, - () -> getClient(container).performRequest(new Request("GET", "/_xpack/"))); + assertThat(catchThrowable(() -> getClient(container).performRequest(new Request("GET", "/_xpack/")))) + .as("We should not have /_xpack endpoint with an OSS License") + .isInstanceOf(ResponseException.class); } } @Test - public void restClientClusterHealth() throws IOException { - // httpClientContainer { + 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... @@ -167,45 +191,138 @@ public void restClientClusterHealth() throws IOException { // Do whatever you want with the rest client ... final CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); - credentialsProvider.setCredentials(AuthScope.ANY, - new UsernamePasswordCredentials(ELASTICSEARCH_USERNAME, ELASTICSEARCH_PASSWORD)); + credentialsProvider.setCredentials( + AuthScope.ANY, + new UsernamePasswordCredentials(ELASTICSEARCH_USERNAME, ELASTICSEARCH_PASSWORD) + ); + + client = + RestClient + .builder(HttpHost.create(container.getHttpHostAddress())) + .setHttpClientConfigCallback(httpClientBuilder -> { + return httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + }) + .build(); - client = RestClient.builder(HttpHost.create(container.getHttpHostAddress())) - .setHttpClientConfigCallback(httpClientBuilder -> 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(), is(200)); - assertThat(EntityUtils.toString(response.getEntity()), containsString("cluster_name")); - // httpClientContainer {{ + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + assertThat(EntityUtils.toString(response.getEntity())).contains("cluster_name"); + // httpClientContainer8 {{ } // } } @Test - public void restClientSecuredClusterHealth() throws IOException { + 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)) { + 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 -> httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider)) - .build(); + 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(), is(200)); - assertThat(EntityUtils.toString(response.getEntity()), containsString("cluster_name")); + assertThat(response.getStatusLine().getStatusCode()).isEqualTo(200); + assertThat(EntityUtils.toString(response.getEntity())).contains("cluster_name"); // httpClientSecuredContainer {{ } // } @@ -213,10 +330,10 @@ public void restClientSecuredClusterHealth() throws IOException { @SuppressWarnings("deprecation") // The TransportClient will be removed in Elasticsearch 8. @Test - public void transportClientClusterHealth() { + void transportClientClusterHealth() { // transportClientContainer { // Create the elasticsearch container. - try (ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE)){ + try (ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE)) { // Start the container. This step might take some time... container.start(); @@ -224,12 +341,14 @@ public void transportClientClusterHealth() { 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)) { + try ( + TransportClient transportClient = new PreBuiltTransportClient(settings) + .addTransportAddress(transportAddress) + ) { ClusterHealthResponse healths = transportClient.admin().cluster().prepareHealth().get(); String clusterName = healths.getClusterName(); // }}} - assertThat(clusterName, is(expectedClusterName)); + assertThat(clusterName).isEqualTo(expectedClusterName); // transportClientContainer {{{ } } @@ -237,27 +356,225 @@ public void transportClientClusterHealth() { } @Test - public void incompatibleSettingsTest() { + void incompatibleSettingsTest() { // The OSS image can not use security feature - assertThrows("We should not be able to activate security with an OSS License", - IllegalArgumentException.class, - () -> new ElasticsearchContainer( - DockerImageName - .parse("docker.elastic.co/elasticsearch/elasticsearch-oss") - .withTag(ELASTICSEARCH_VERSION)) - .withPassword("foo") + 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)); - - client = RestClient.builder(HttpHost.create(container.getHttpHostAddress())) - .setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider)) - .build(); + 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; @@ -270,4 +587,52 @@ private RestClient getAnonymousClient(ElasticsearchContainer container) { 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 index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/elasticsearch/src/test/resources/logback-test.xml +++ b/modules/elasticsearch/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + 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 index aa42d7dc560..3a020bdb759 100644 --- a/modules/gcloud/build.gradle +++ b/modules/gcloud/build.gradle @@ -1,12 +1,13 @@ description = "Testcontainers :: GCloud" dependencies { - compile project(':testcontainers') + api project(':testcontainers') - testCompile 'com.google.cloud:google-cloud-datastore:1.102.4' - testCompile 'com.google.cloud:google-cloud-firestore:1.33.0' - testCompile 'com.google.cloud:google-cloud-pubsub:1.105.0' - testCompile 'com.google.cloud:google-cloud-spanner:1.50.0' - testCompile 'com.google.cloud:google-cloud-bigtable:1.17.3' - testCompile 'org.assertj:assertj-core:3.15.0' + 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 index 4b81e3964ce..288cd499e37 100644 --- a/modules/gcloud/src/main/java/org/testcontainers/containers/BigtableEmulatorContainer.java +++ b/modules/gcloud/src/main/java/org/testcontainers/containers/BigtableEmulatorContainer.java @@ -1,31 +1,42 @@ package org.testcontainers.containers; -import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +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. * - * @author Eddú Meléndez - * @author Ray Tsang + * @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/cloud-sdk"); + 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); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, CLOUD_SDK_IMAGE_NAME); withExposedPorts(PORT); - setWaitStrategy(new LogMessageWaitStrategy() - .withRegEx("(?s).*running.*$")); + setWaitStrategy(Wait.forLogMessage(".*running.*$", 1)); withCommand("/bin/sh", "-c", CMD); } diff --git a/modules/gcloud/src/main/java/org/testcontainers/containers/DatastoreEmulatorContainer.java b/modules/gcloud/src/main/java/org/testcontainers/containers/DatastoreEmulatorContainer.java index fe0a9959f2d..cbe27e303f4 100644 --- a/modules/gcloud/src/main/java/org/testcontainers/containers/DatastoreEmulatorContainer.java +++ b/modules/gcloud/src/main/java/org/testcontainers/containers/DatastoreEmulatorContainer.java @@ -5,26 +5,59 @@ /** * 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. * - * @author Eddú Meléndez + * @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/cloud-sdk"); + 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 String CMD = "gcloud beta emulators datastore start --project test-project --host-port 0.0.0.0:8081"; 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); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, CLOUD_SDK_IMAGE_NAME); withExposedPorts(HTTP_PORT); setWaitStrategy(Wait.forHttp("/").forStatusCode(200)); - withCommand("/bin/sh", "-c", CMD); + } + + @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; } /** @@ -33,6 +66,10 @@ public DatastoreEmulatorContainer(final DockerImageName dockerImageName) { * com.google.cloud.ServiceOptions.Builder#setHost(java.lang.String) method. */ public String getEmulatorEndpoint() { - return getContainerIpAddress() + ":" + getMappedPort(8081); + 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 index 6353a679130..3c8d12f82ad 100644 --- a/modules/gcloud/src/main/java/org/testcontainers/containers/FirestoreEmulatorContainer.java +++ b/modules/gcloud/src/main/java/org/testcontainers/containers/FirestoreEmulatorContainer.java @@ -1,31 +1,58 @@ package org.testcontainers.containers; -import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +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. * - * @author Eddú Meléndez + * @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/cloud-sdk"); + 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); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, CLOUD_SDK_IMAGE_NAME); withExposedPorts(PORT); - setWaitStrategy(new LogMessageWaitStrategy() - .withRegEx("(?s).*running.*$")); - withCommand("/bin/sh", "-c", CMD); + 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; } /** @@ -34,6 +61,6 @@ public FirestoreEmulatorContainer(final DockerImageName dockerImageName) { * com.google.cloud.ServiceOptions.Builder#setHost(java.lang.String) method. */ public String getEmulatorEndpoint() { - return getContainerIpAddress() + ":" + getMappedPort(8080); + 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 index 90d77c837cf..9f061873962 100644 --- a/modules/gcloud/src/main/java/org/testcontainers/containers/PubSubEmulatorContainer.java +++ b/modules/gcloud/src/main/java/org/testcontainers/containers/PubSubEmulatorContainer.java @@ -1,30 +1,42 @@ package org.testcontainers.containers; -import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +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. * - * @author Eddú Meléndez + * @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/cloud-sdk"); + 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); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, CLOUD_SDK_IMAGE_NAME); withExposedPorts(8085); - setWaitStrategy(new LogMessageWaitStrategy() - .withRegEx("(?s).*started.*$")); + setWaitStrategy(Wait.forLogMessage(".*started.*$", 1)); withCommand("/bin/sh", "-c", CMD); } @@ -34,6 +46,6 @@ public PubSubEmulatorContainer(final DockerImageName dockerImageName) { * io.grpc.ManagedChannelBuilder#forTarget(java.lang.String) method. */ public String getEmulatorEndpoint() { - return getContainerIpAddress() + ":" + getMappedPort(PORT); + 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 index 8603b1f3437..76a95d4c4ac 100644 --- a/modules/gcloud/src/main/java/org/testcontainers/containers/SpannerEmulatorContainer.java +++ b/modules/gcloud/src/main/java/org/testcontainers/containers/SpannerEmulatorContainer.java @@ -1,28 +1,36 @@ package org.testcontainers.containers; -import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +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} * - * @author Eddú Meléndez + * @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 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(new LogMessageWaitStrategy() - .withRegEx(".*Cloud Spanner emulator running\\..*")); + setWaitStrategy(Wait.forLogMessage(".*Cloud Spanner emulator running\\..*", 1)); } /** @@ -31,7 +39,7 @@ public SpannerEmulatorContainer(final DockerImageName dockerImageName) { * com.google.cloud.spanner.SpannerOptions.Builder#setEmulatorHost(java.lang.String) method. */ public String getEmulatorGrpcEndpoint() { - return getContainerIpAddress() + ":" + getMappedPort(GRPC_PORT); + return getHost() + ":" + getMappedPort(GRPC_PORT); } /** @@ -39,6 +47,6 @@ public String getEmulatorGrpcEndpoint() { * HTTP REST endpoint is reachable from the test host machine. */ public String getEmulatorHttpEndpoint() { - return getContainerIpAddress() + ":" + getMappedPort(HTTP_PORT); + 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/containers/BigtableEmulatorContainerTest.java b/modules/gcloud/src/test/java/org/testcontainers/containers/BigtableEmulatorContainerTest.java deleted file mode 100644 index 0cc44afcc77..00000000000 --- a/modules/gcloud/src/test/java/org/testcontainers/containers/BigtableEmulatorContainerTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package org.testcontainers.containers; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.google.api.core.ApiFuture; -import com.google.api.gax.batching.Batcher; -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.BigtableInstanceAdminClient; -import com.google.cloud.bigtable.admin.v2.BigtableTableAdminClient; -import com.google.cloud.bigtable.admin.v2.models.CreateInstanceRequest; -import com.google.cloud.bigtable.admin.v2.models.CreateTableRequest; -import com.google.cloud.bigtable.admin.v2.models.Instance; -import com.google.cloud.bigtable.admin.v2.models.Table; -import com.google.cloud.bigtable.admin.v2.stub.BigtableInstanceAdminStub; -import com.google.cloud.bigtable.admin.v2.stub.BigtableInstanceAdminStubSettings; -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.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.RowMutationEntry; -import io.grpc.ManagedChannel; -import io.grpc.ManagedChannelBuilder; -import java.io.IOException; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.ExecutionException; -import org.junit.Rule; -import org.junit.Test; -import org.testcontainers.utility.DockerImageName; - -public class BigtableEmulatorContainerTest { - - public static final String PROJECT_ID = "test-project"; - public static final String INSTANCE_ID = "test-instance"; - - @Rule - // emulatorContainer { - public BigtableEmulatorContainer emulator = new BigtableEmulatorContainer( - DockerImageName.parse("gcr.io/google.com/cloudsdktool/cloud-sdk:316.0.0-emulators") - ); - // } - - @Test - // testWithEmulatorContainer { - public void testSimple() throws IOException, InterruptedException, ExecutionException { - ManagedChannel channel = ManagedChannelBuilder.forTarget(emulator.getEmulatorEndpoint()) - .usePlaintext().build(); - - TransportChannelProvider channelProvider = FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel)); - NoCredentialsProvider credentialsProvider = NoCredentialsProvider.create(); - - try { - createTable(channelProvider, credentialsProvider, "test-table" ); - - BigtableDataClient client = BigtableDataClient.create(BigtableDataSettings - .newBuilderForEmulator(emulator.getHost(), emulator.getEmulatorPort()) - .setProjectId(PROJECT_ID) - .setInstanceId(INSTANCE_ID) - .build()); - - client.mutateRow(RowMutation.create("test-table", "1") - .setCell("name", "firstName", "Ray")); - - Row row = client.readRow("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 { - EnhancedBigtableTableAdminStub stub = EnhancedBigtableTableAdminStub - .createEnhanced(BigtableTableAdminStubSettings.newBuilder() - .setTransportChannelProvider(channelProvider) - .setCredentialsProvider(credentialsProvider) - .build()); - - 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/containers/DatastoreEmulatorContainerTest.java b/modules/gcloud/src/test/java/org/testcontainers/containers/DatastoreEmulatorContainerTest.java deleted file mode 100644 index f7b35936197..00000000000 --- a/modules/gcloud/src/test/java/org/testcontainers/containers/DatastoreEmulatorContainerTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.testcontainers.containers; - -import static org.assertj.core.api.Assertions.assertThat; - -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.Rule; -import org.junit.Test; -import org.testcontainers.utility.DockerImageName; - -public class DatastoreEmulatorContainerTest { - - @Rule - // creatingDatastoreEmulatorContainer { - public DatastoreEmulatorContainer emulator = new DatastoreEmulatorContainer( - DockerImageName.parse("gcr.io/google.com/cloudsdktool/cloud-sdk:316.0.0-emulators") - ); - // } - - // startingDatastoreEmulatorContainer { - @Test - public void testSimple() { - DatastoreOptions options = DatastoreOptions.newBuilder() - .setHost(emulator.getEmulatorEndpoint()) - .setCredentials(NoCredentials.getInstance()) - .setRetrySettings(ServiceOptions.getNoRetrySettings()) - .setProjectId("test-project") - .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"); - } - // } - -} diff --git a/modules/gcloud/src/test/java/org/testcontainers/containers/FirestoreEmulatorContainerTest.java b/modules/gcloud/src/test/java/org/testcontainers/containers/FirestoreEmulatorContainerTest.java deleted file mode 100644 index 138ff946f6f..00000000000 --- a/modules/gcloud/src/test/java/org/testcontainers/containers/FirestoreEmulatorContainerTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.testcontainers.containers; - -import static org.assertj.core.api.Assertions.assertThat; - -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 java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ExecutionException; -import org.junit.Rule; -import org.junit.Test; -import org.testcontainers.utility.DockerImageName; - -public class FirestoreEmulatorContainerTest { - - @Rule - // emulatorContainer { - public FirestoreEmulatorContainer emulator = new FirestoreEmulatorContainer( - DockerImageName.parse("gcr.io/google.com/cloudsdktool/cloud-sdk:316.0.0-emulators") - ); - // } - - // testWithEmulatorContainer { - @Test - public void testSimple() throws ExecutionException, InterruptedException { - 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"); - } - // } - -} diff --git a/modules/gcloud/src/test/java/org/testcontainers/containers/PubSubEmulatorContainerTest.java b/modules/gcloud/src/test/java/org/testcontainers/containers/PubSubEmulatorContainerTest.java deleted file mode 100644 index 2e692956418..00000000000 --- a/modules/gcloud/src/test/java/org/testcontainers/containers/PubSubEmulatorContainerTest.java +++ /dev/null @@ -1,112 +0,0 @@ -package org.testcontainers.containers; - -import static org.assertj.core.api.Assertions.assertThat; - -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.TopicName; -import io.grpc.ManagedChannel; -import io.grpc.ManagedChannelBuilder; -import java.io.IOException; -import org.junit.Rule; -import org.junit.Test; -import org.testcontainers.utility.DockerImageName; - -public class PubSubEmulatorContainerTest { - - public static final String PROJECT_ID = "my-project-id"; - - @Rule - // emulatorContainer { - public PubSubEmulatorContainer emulator = new PubSubEmulatorContainer( - DockerImageName.parse("gcr.io/google.com/cloudsdktool/cloud-sdk:316.0.0-emulators") - ); - // } - - - @Test - // testWithEmulatorContainer { - public void testSimple() throws IOException { - 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); - ProjectSubscriptionName subscriptionName = ProjectSubscriptionName.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/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/containers/SpannerEmulatorContainerTest.java b/modules/gcloud/src/test/java/org/testcontainers/gcloud/SpannerEmulatorContainerTest.java similarity index 57% rename from modules/gcloud/src/test/java/org/testcontainers/containers/SpannerEmulatorContainerTest.java rename to modules/gcloud/src/test/java/org/testcontainers/gcloud/SpannerEmulatorContainerTest.java index f801ca5b547..2d6ac963882 100644 --- a/modules/gcloud/src/test/java/org/testcontainers/containers/SpannerEmulatorContainerTest.java +++ b/modules/gcloud/src/test/java/org/testcontainers/gcloud/SpannerEmulatorContainerTest.java @@ -1,6 +1,4 @@ -package org.testcontainers.containers; - -import static org.assertj.core.api.Assertions.assertThat; +package org.testcontainers.gcloud; import com.google.cloud.NoCredentials; import com.google.cloud.spanner.Database; @@ -16,43 +14,50 @@ 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 org.junit.Rule; -import org.junit.Test; -import org.testcontainers.utility.DockerImageName; -public class SpannerEmulatorContainerTest { +import static org.assertj.core.api.Assertions.assertThat; - @Rule - // emulatorContainer { - public SpannerEmulatorContainer emulator = new SpannerEmulatorContainer( - DockerImageName.parse("gcr.io/cloud-spanner-emulator/emulator:1.1.0") - ); - // } +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 - public void testSimple() throws ExecutionException, InterruptedException { - SpannerOptions options = SpannerOptions.newBuilder() + 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(); + Spanner spanner = options.getService(); - InstanceId instanceId = createInstance(spanner); + InstanceId instanceId = createInstance(spanner); - createDatabase(spanner); + createDatabase(spanner); - DatabaseId databaseId = DatabaseId.of(instanceId, DATABASE_NAME); - DatabaseClient dbClient = spanner.getDatabaseClient(databaseId); - dbClient.readWriteTransaction() + 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)); @@ -61,19 +66,29 @@ public void testSimple() throws ExecutionException, InterruptedException { return null; }); - ResultSet resultSet = dbClient.readOnlyTransaction() + 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"); + 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(); + Database database = dbAdminClient + .createDatabase( + INSTANCE_NAME, + DATABASE_NAME, + Arrays.asList("CREATE TABLE TestTable (Key INT64, Value STRING(MAX)) PRIMARY KEY (Key)") + ) + .get(); } + // } // createInstance { @@ -81,7 +96,16 @@ private InstanceId createInstance(Spanner spanner) throws InterruptedException, 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(); + 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 index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/gcloud/src/test/resources/logback-test.xml +++ b/modules/gcloud/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + 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 index 2e735e1137a..145aa4aac52 100644 --- a/modules/influxdb/build.gradle +++ b/modules/influxdb/build.gradle @@ -1,8 +1,10 @@ description = "Testcontainers :: InfluxDB" dependencies { - compile project(':testcontainers') + api project(':testcontainers') - compileOnly 'org.influxdb:influxdb-java:2.21' - testCompile 'org.influxdb:influxdb-java:2.21' + 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 index 7ebfbff1a86..8e908e8e229 100644 --- a/modules/influxdb/src/main/java/org/testcontainers/containers/InfluxDBContainer.java +++ b/modules/influxdb/src/main/java/org/testcontainers/containers/InfluxDBContainer.java @@ -1,37 +1,73 @@ package org.testcontainers.containers; +import lombok.Getter; import org.influxdb.InfluxDB; import org.influxdb.InfluxDBFactory; -import org.testcontainers.containers.wait.strategy.Wait; -import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +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; /** - * See https://store.docker.com/images/influxdb + * 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; - private String username = "any"; - private String password = "any"; /** - * @deprecated use {@link InfluxDBContainer(DockerImageName)} instead + * 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() { @@ -39,7 +75,7 @@ public InfluxDBContainer() { } /** - * @deprecated use {@link InfluxDBContainer(DockerImageName)} instead + * @deprecated use {@link #InfluxDBContainer(DockerImageName)} instead */ @Deprecated public InfluxDBContainer(final String version) { @@ -48,26 +84,63 @@ public InfluxDBContainer(final String version) { public InfluxDBContainer(final DockerImageName dockerImageName) { super(dockerImageName); - dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); - waitStrategy = new WaitAllStrategy() - .withStrategy(Wait.forHttp("/ping").withBasicCredentials(username, password).forStatusCode(204)) - .withStrategy(Wait.forListeningPort()); + 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() { - addEnv("INFLUXDB_ADMIN_USER", admin); - addEnv("INFLUXDB_ADMIN_PASSWORD", adminPassword); + 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("INFLUXDB_HTTP_AUTH_ENABLED", String.valueOf(authEnabled)); + addEnv("DOCKER_INFLUXDB_INIT_USERNAME", this.username); + addEnv("DOCKER_INFLUXDB_INIT_PASSWORD", this.password); - addEnv("INFLUXDB_DB", database); - addEnv("INFLUXDB_USER", username); - addEnv("INFLUXDB_USER_PASSWORD", 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 @@ -76,88 +149,131 @@ public Set getLivenessCheckPortNumbers() { } /** - * Set env variable `INFLUXDB_HTTP_AUTH_ENABLED`. + * 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 SELF withAuthEnabled(final boolean authEnabled) { + public InfluxDBContainer withAuthEnabled(final boolean authEnabled) { this.authEnabled = authEnabled; - return self(); + return this.self(); } /** - * Set env variable `INFLUXDB_ADMIN_USER`. + * 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 SELF withAdmin(final String admin) { + public InfluxDBContainer withAdmin(final String admin) { this.admin = admin; - return self(); + return this.self(); } /** - * Set env variable `INFLUXDB_ADMIN_PASSWORD`. + * Sets the admin password * - * @param adminPassword TThe password for the admin user configured with `INFLUXDB_ADMIN_USER`. If this is unset, a - * random password is generated and printed to standard out. + * @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 SELF withAdminPassword(final String adminPassword) { + public InfluxDBContainer withAdminPassword(final String adminPassword) { this.adminPassword = adminPassword; - return self(); + return this.self(); } /** - * Set env variable `INFLUXDB_DB`. + * Initializes database with given name * - * @param database Automatically initializes a database with the name of this environment variable. + * @param database name of the database. * @return a reference to this container instance */ - public SELF withDatabase(final String database) { + public InfluxDBContainer withDatabase(final String database) { this.database = database; - return self(); + return this.self(); } /** - * Set env variable `INFLUXDB_USER`. + * Sets the organization name * - * @param username The name of a user to be created with no privileges. If `INFLUXDB_DB` is set, this user will - * be granted read and write permissions for that database. + * @param organization The organization for the initial setup of influxDB. * @return a reference to this container instance */ - public SELF withUsername(final String username) { - this.username = username; - return self(); + public InfluxDBContainer withOrganization(final String organization) { + this.organization = organization; + return this; } /** - * Set env variable `INFLUXDB_USER_PASSWORD`. + * Initializes bucket with given name * - * @param password The password for the user configured with `INFLUXDB_USER`. If this is unset, a random password - * is generated and printed to standard out. + * @param bucket name of the bucket. * @return a reference to this container instance */ - public SELF withPassword(final String password) { - this.password = password; - return self(); + 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 + * @return a url to InfluxDB */ public String getUrl() { - return "http://" + getHost() + ":" + getLivenessCheckPort(); + return "http://" + getHost() + ":" + getMappedPort(INFLUXDB_PORT); } /** - * @return a influxDb client + * @return a InfluxDB client for InfluxDB 1.x. + * @deprecated Use the new InfluxDB client library. */ + @Deprecated public InfluxDB getNewInfluxDB() { - InfluxDB influxDB = InfluxDBFactory.connect(getUrl(), username, password); - influxDB.setDatabase(database); + 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 index 7acf030b5e4..53678fa6dce 100644 --- a/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerTest.java +++ b/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerTest.java @@ -1,44 +1,166 @@ package org.testcontainers.containers; -import org.influxdb.InfluxDB; -import org.junit.ClassRule; -import org.junit.Test; +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 static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.notNullValue; -import static org.junit.Assert.assertThat; +import java.time.Instant; +import java.util.List; +import java.util.Optional; -public class InfluxDBContainerTest { +import static org.assertj.core.api.Assertions.assertThat; - @ClassRule - public static InfluxDBContainer influxDBContainer = new InfluxDBContainer<>(InfluxDBTestImages.INFLUXDB_TEST_IMAGE); +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 - public void getUrl() { - String actual = influxDBContainer.getUrl(); + void getInfluxDBClient() { + try ( + // constructorWithDefaultVariables { + final InfluxDBContainer influxDBContainer = new InfluxDBContainer<>( + DockerImageName.parse("influxdb:2.0.7") + ) + // } + ) { + influxDBContainer.start(); - assertThat(actual, notNullValue()); + try (final InfluxDBClient influxDBClient = createClient(influxDBContainer)) { + assertThat(influxDBClient).isNotNull(); + assertThat(influxDBClient.ping()).isTrue(); + } + } } @Test - public void getNewInfluxDB() { - InfluxDB actual = influxDBContainer.getNewInfluxDB(); + 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(); - assertThat(actual, notNullValue()); - assertThat(actual.ping(), notNullValue()); + try ( + final InfluxDBClient influxDBClient = createClientWithToken( + influxDBContainer.getUrl(), + adminToken.get() + ) + ) { + assertThat(influxDBClient).isNotNull(); + assertThat(influxDBClient.ping()).isTrue(); + } + } } @Test - public void getLivenessCheckPort() { - Integer actual = influxDBContainer.getLivenessCheckPort(); + 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(); - assertThat(actual, notNullValue()); + 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 - public void isRunning() { - boolean actual = influxDBContainer.isRunning(); + 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); + } + + // } - assertThat(actual, is(true)); + 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/InfluxDBContainerWithUserTest.java b/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerWithUserTest.java deleted file mode 100644 index b045ca297c1..00000000000 --- a/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBContainerWithUserTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.testcontainers.containers; - -import org.influxdb.InfluxDB; -import org.influxdb.dto.Point; -import org.influxdb.dto.Query; -import org.influxdb.dto.QueryResult; -import org.junit.Rule; -import org.junit.Test; - -import java.util.concurrent.TimeUnit; - -import static org.hamcrest.CoreMatchers.hasItem; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.notNullValue; -import static org.hamcrest.CoreMatchers.nullValue; -import static org.junit.Assert.assertThat; - -public class InfluxDBContainerWithUserTest { - - private static final String TEST_VERSION = InfluxDBTestImages.INFLUXDB_TEST_IMAGE.getVersionPart(); - private static final String DATABASE = "test"; - private static final String USER = "test-user"; - private static final String PASSWORD = "test-password"; - - @Rule - public InfluxDBContainer influxDBContainer = new InfluxDBContainer<>(InfluxDBTestImages.INFLUXDB_TEST_IMAGE) - .withDatabase(DATABASE) - .withUsername(USER) - .withPassword(PASSWORD); - - @Test - public void describeDatabases() { - InfluxDB actual = influxDBContainer.getNewInfluxDB(); - - assertThat(actual, notNullValue()); - assertThat(actual.describeDatabases(), hasItem(DATABASE)); - } - - @Test - public void checkVersion() { - InfluxDB actual = influxDBContainer.getNewInfluxDB(); - - assertThat(actual, notNullValue()); - - assertThat(actual.ping(), notNullValue()); - assertThat(actual.ping().getVersion(), is(TEST_VERSION)); - - assertThat(actual.version(), is(TEST_VERSION)); - } - - @Test - public void queryForWriteAndRead() { - InfluxDB influxDB = influxDBContainer.getNewInfluxDB(); - - Point point = Point.measurement("cpu") - .time(System.currentTimeMillis(), TimeUnit.MILLISECONDS) - .addField("idle", 90L) - .addField("user", 9L) - .addField("system", 1L) - .build(); - influxDB.write(point); - - Query query = new Query("SELECT idle FROM cpu", DATABASE); - QueryResult actual = influxDB.query(query); - - assertThat(actual, notNullValue()); - assertThat(actual.getError(), nullValue()); - assertThat(actual.getResults(), notNullValue()); - assertThat(actual.getResults().size(), is(1)); - - } -} diff --git a/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBTestImages.java b/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBTestImages.java deleted file mode 100644 index a0e2d31bbdf..00000000000 --- a/modules/influxdb/src/test/java/org/testcontainers/containers/InfluxDBTestImages.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.testcontainers.containers; - -import org.testcontainers.utility.DockerImageName; - -public interface InfluxDBTestImages { - DockerImageName INFLUXDB_TEST_IMAGE = DockerImageName.parse("influxdb:1.4.3"); -} 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 index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/influxdb/src/test/resources/logback-test.xml +++ b/modules/influxdb/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + diff --git a/modules/jdbc-test/build.gradle b/modules/jdbc-test/build.gradle index 05a96aaf333..44fe705a255 100644 --- a/modules/jdbc-test/build.gradle +++ b/modules/jdbc-test/build.gradle @@ -1,20 +1,15 @@ dependencies { - compile project(':jdbc') - compile project(':test-support') + api project(':testcontainers-jdbc') - compile 'com.google.guava:guava:30.1-jre' - compile 'org.apache.commons:commons-lang3:3.11' - compile 'com.zaxxer:HikariCP-java6:2.3.13' - compile 'commons-dbutils:commons-dbutils:1.7' + 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' - compile 'com.googlecode.junit-toolbox:junit-toolbox:2.4' + api 'org.assertj:assertj-core:3.27.7' - compile('org.rnorth.visible-assertions:visible-assertions:2.1.2') { - // Excluded in favor of jna-platform - exclude(group: "net.java.dev.jna", module: "jna") - } - - compile 'org.apache.tomcat:tomcat-jdbc:9.0.40' - compile 'org.vibur:vibur-dbcp:25.0' - compile 'mysql:mysql-connector-java:8.0.22' + 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/src/main/java/org/testcontainers/db/AbstractContainerDatabaseTest.java b/modules/jdbc-test/src/main/java/org/testcontainers/db/AbstractContainerDatabaseTest.java index 8881ed963d8..6d3080c7ee3 100644 --- a/modules/jdbc-test/src/main/java/org/testcontainers/db/AbstractContainerDatabaseTest.java +++ b/modules/jdbc-test/src/main/java/org/testcontainers/db/AbstractContainerDatabaseTest.java @@ -4,11 +4,12 @@ import com.zaxxer.hikari.HikariDataSource; import org.testcontainers.containers.JdbcDatabaseContainer; -import javax.sql.DataSource; 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 { @@ -27,7 +28,6 @@ protected DataSource getDataSource(JdbcDatabaseContainer container) { 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 index 38ca2237567..95f9d5d1ad4 100644 --- a/modules/jdbc-test/src/main/java/org/testcontainers/jdbc/AbstractJDBCDriverTest.java +++ b/modules/jdbc-test/src/main/java/org/testcontainers/jdbc/AbstractJDBCDriverTest.java @@ -3,10 +3,12 @@ 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.runners.Parameterized.Parameter; +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; @@ -14,10 +16,11 @@ import java.sql.Statement; import java.util.EnumSet; -import static org.junit.Assume.assumeFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +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 { @@ -25,31 +28,28 @@ protected enum Options { CharacterSet, CustomIniFile, JDBCParams, - PmdKnownBroken + PmdKnownBroken, } - @Parameter + @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("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" + ");"); } - @AfterClass + @AfterAll public static void testCleanup() { ContainerDatabaseDriver.killContainers(); } @Test - public void test() throws SQLException { + void test() throws SQLException { try (HikariDataSource dataSource = getDataSource(jdbcUrl, 1)) { performSimpleTest(dataSource); @@ -79,67 +79,98 @@ private void performSimpleTest(HikariDataSource dataSource) throws SQLException 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); - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - return true; - }); - - assertTrue("The database returned a record as expected", result); + 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); - 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); + 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); - } - assertEquals("User from query param is created.", "someuser", resultUser); - return true; - }); - - assertTrue("The database returned a record as expected", result); + 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")) { + 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); - assertEquals("Database name from URL String is used.", "databasename", resultDB); - return true; - }); - - assertTrue("The database returned a record as expected", result); + 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); - assertEquals("A SELECT query succeed and the correct charset has been applied for the init script", "привет мир", resultSetString); - return true; - }); - - assertTrue("The database returned a record as expected", result); + 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(); } /** @@ -158,27 +189,33 @@ private void performSimpleTestWithCharacterSet(String jdbcUrl) throws SQLExcepti 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); - assertTrue("Passing query parameters to set DB connection encoding is successful", resultSetString.startsWith("utf8")); - return true; - }); - - assertTrue("The database returned a record as expected", result); + 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 { - assumeFalse(SystemUtils.IS_OS_WINDOWS); + assumeThat(SystemUtils.IS_OS_WINDOWS).isFalse(); Statement statement = dataSource.getConnection().createStatement(); - statement.execute("SELECT @@GLOBAL.innodb_file_format"); + statement.execute("SELECT @@GLOBAL.innodb_max_undo_log_size"); ResultSet resultSet = statement.getResultSet(); - assertTrue("The query returns a result", resultSet.next()); - String result = resultSet.getString(1); + assertThat(resultSet.next()).as("The query returns a result").isTrue(); + long result = resultSet.getLong(1); - assertEquals("The InnoDB file format has been set by the ini file content", "Barracuda", result); + 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) { diff --git a/modules/jdbc-test/src/main/resources/logback-test.xml b/modules/jdbc-test/src/main/resources/logback-test.xml index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/jdbc-test/src/main/resources/logback-test.xml +++ b/modules/jdbc-test/src/main/resources/logback-test.xml @@ -12,5 +12,5 @@ - + diff --git a/modules/jdbc/build.gradle b/modules/jdbc/build.gradle index 910dc29ebad..4d538fe2720 100644 --- a/modules/jdbc/build.gradle +++ b/modules/jdbc/build.gradle @@ -1,13 +1,14 @@ description = "Testcontainers :: JDBC" dependencies { - compile project(':database-commons') - testCompile project(':test-support') + api project(':testcontainers-database-commons') - compileOnly 'org.jetbrains:annotations:20.1.0' - testCompile 'commons-dbutils:commons-dbutils:1.7' - testCompile 'org.vibur:vibur-dbcp:25.0' - testCompile 'org.apache.tomcat:tomcat-jdbc:10.0.0' - testCompile 'com.zaxxer:HikariCP-java6:2.3.13' - testCompile 'com.googlecode.junit-toolbox:junit-toolbox:2.4' + 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/src/main/java/org/testcontainers/containers/JdbcDatabaseContainer.java b/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainer.java index 26d699a1813..cf6c995528f 100644 --- a/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainer.java +++ b/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainer.java @@ -2,8 +2,10 @@ import com.github.dockerjava.api.command.InspectContainerResponse; import lombok.NonNull; -import org.apache.commons.lang.StringUtils; +import lombok.SneakyThrows; +import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.testcontainers.containers.traits.LinkableContainer; import org.testcontainers.delegate.DatabaseDelegate; import org.testcontainers.ext.ScriptUtils; @@ -14,33 +16,43 @@ 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 static java.util.stream.Collectors.joining; +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 String initScriptPath; + + private List initScriptPaths = new ArrayList<>(); + protected Map parameters = new HashMap<>(); + 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 + * @deprecated use {@link #JdbcDatabaseContainer(DockerImageName)} instead */ public JdbcDatabaseContainer(@NonNull final String dockerImageName) { this(DockerImageName.parse(dockerImageName)); @@ -125,50 +137,87 @@ public SELF withConnectTimeoutSeconds(int connectTimeoutSeconds) { return self(); } + /** + * Sets a script for initialization. + * + * @param initScriptPath path to the script file + * @return self + */ public SELF withInitScript(String initScriptPath) { - this.initScriptPath = 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() { - 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() + ); // Repeatedly try and open a connection to the DB and execute a test query - long start = System.currentTimeMillis(); - try { - while (System.currentTimeMillis() < start + (1000 * startupTimeoutSeconds)) { - try { - if (!isRunning()) { - Thread.sleep(100L); - continue; // Don't attempt to connect yet - } - - try (Connection connection = createConnection("")) { - boolean testQuerySucceeded = connection.createStatement().execute(this.getTestQueryString()); - if (testQuerySucceeded) { - break; - } + long start = System.nanoTime(); + + Exception lastConnectionException = null; + while ((System.nanoTime() - start) < TimeUnit.SECONDS.toNanos(startupTimeoutSeconds)) { + if (!isRunning()) { + 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); } } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new ContainerLaunchException("Container startup wait was interrupted", e); } - logger().info("Container is started (JDBC URL: {})", JdbcDatabaseContainer.this.getJdbcUrl()); + throw new IllegalStateException( + String.format( + "Container is started, but cannot be accessed by (JDBC URL: %s), please check container logs", + this.getJdbcUrl() + ), + lastConnectionException + ); } @Override protected void containerIsStarted(InspectContainerResponse containerInfo) { + logger().info("Container is started (JDBC URL: {})", this.getJdbcUrl()); runInitScriptIfRequired(); } @@ -178,7 +227,6 @@ protected void containerIsStarted(InspectContainerResponse containerInfo) { * @return a JDBC Driver */ public Driver getJdbcDriverInstance() throws NoDriverFoundException { - synchronized (DRIVER_LOAD_MUTEX) { if (driver == null) { try { @@ -201,22 +249,42 @@ public Driver getJdbcDriverInstance() throws NoDriverFoundException { * @throws SQLException if there is a repeated failure to create the connection */ public Connection createConnection(String queryString) throws SQLException, NoDriverFoundException { - final Properties info = new Properties(); - info.put("user", this.getUsername()); - info.put("password", this.getPassword()); + 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, 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 { - long start = System.currentTimeMillis(); + long start = System.nanoTime(); // give up if we hit the time limit or the container stops running for some reason - while (System.currentTimeMillis() < start + (1000 * connectTimeoutSeconds) && isRunning()) { + while ((System.nanoTime() - start < TimeUnit.SECONDS.toNanos(connectTimeoutSeconds)) && isRunning()) { try { - logger().debug("Trying to create JDBC connection using {} to {} with properties: {}", driver.getClass().getName(), url, info); - - return jdbcDriverInstance.connect(url, info); + 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); @@ -260,19 +328,32 @@ protected String constructUrlParameters(String startCharacter, String delimiter) 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(joining(delimiter)); + String additionalParameters = + this.urlParameters.entrySet().stream().map(Object::toString).collect(Collectors.joining(delimiter)); urlParameters = startCharacter + additionalParameters + endCharacter; } return urlParameters; } - protected void optionallyMapResourceParameterAsVolume(@NotNull String paramName, @NotNull String pathNameInContainer, @NotNull String defaultResource) { + @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); + final MountableFile mountableFile = MountableFile.forClasspathResource(resourceName, fileMode); withCopyFileToContainer(mountableFile, pathNameInContainer); } } @@ -281,9 +362,10 @@ protected void optionallyMapResourceParameterAsVolume(@NotNull String paramName, * Load init script content and apply it to the database if initScriptPath is set */ protected void runInitScriptIfRequired() { - if (initScriptPath != null) { - ScriptUtils.runInitScript(getDatabaseDelegate(), initScriptPath); - } + initScriptPaths + .stream() + .filter(Objects::nonNull) + .forEach(path -> ScriptUtils.runInitScript(getDatabaseDelegate(), path)); } public void setParameters(Map parameters) { @@ -318,6 +400,7 @@ protected DatabaseDelegate getDatabaseDelegate() { } 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 f8694306384..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,8 +1,7 @@ package org.testcontainers.containers; -import org.testcontainers.jdbc.ConnectionUrl; - import lombok.extern.slf4j.Slf4j; +import org.testcontainers.jdbc.ConnectionUrl; import java.util.Objects; @@ -26,9 +25,12 @@ public abstract class JdbcDatabaseContainerProvider { * @return Instance of {@link JdbcDatabaseContainer} */ public JdbcDatabaseContainer newInstance() { - log.warn("No explicit version tag was provided in JDBC URL and this class ({}) does not " + + 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()); + "be unreliable!", + this.getClass().getCanonicalName() + ); return this.newInstance("latest"); } @@ -55,7 +57,11 @@ public JdbcDatabaseContainer newInstance(ConnectionUrl url) { return result; } - protected JdbcDatabaseContainer newInstanceFromConnectionUrl(ConnectionUrl connectionUrl, final String userParamName, final String pwdParamName) { + 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"); diff --git a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionDelegate.java b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionDelegate.java index e7f59b2df4f..47d3cadc823 100644 --- a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionDelegate.java +++ b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionDelegate.java @@ -1,13 +1,13 @@ package org.testcontainers.jdbc; import lombok.RequiredArgsConstructor; -import lombok.Value; 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 index 4f339b43fe5..ec606f3ed32 100644 --- a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionUrl.java +++ b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionUrl.java @@ -1,5 +1,10 @@ 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; @@ -7,22 +12,13 @@ import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collector; import java.util.stream.Collectors; import java.util.stream.Stream; -import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import org.testcontainers.UnstableAPI; - -import static java.util.stream.Collectors.toMap; /** * 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. - * - * @author manikmagar */ @EqualsAndHashCode(of = "url") @Getter @@ -60,6 +56,7 @@ public class ConnectionUrl { private Map containerParameters; private Map queryParameters; + private Map tmpfsOptions = new HashMap<>(); public static ConnectionUrl newInstance(final String url) { @@ -94,28 +91,31 @@ private void parseUrl() { //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"); + throw new IllegalArgumentException( + "JDBC URL matches jdbc:tc: prefix but the database or tag name could not be identified" + ); } } - databaseType = urlMatcher.group(1); + databaseType = urlMatcher.group("databaseType"); - imageTag = Optional.ofNullable(urlMatcher.group(3)); + 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(4); + 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.of(dbInstanceMatcher.group(1)); - databasePort = Optional.ofNullable(dbInstanceMatcher.group(3)).map(value -> Integer.valueOf(value)); - databaseName = Optional.of(dbInstanceMatcher.group(4)); + 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(5)).orElse(""))); + queryParameters = + Collections.unmodifiableMap( + parseQueryParameters(Optional.ofNullable(urlMatcher.group("queryParameters")).orElse("")) + ); String query = queryParameters .entrySet() @@ -123,7 +123,7 @@ private void parseUrl() { .map(e -> e.getKey() + "=" + e.getValue()) .collect(Collectors.joining("&")); - if (query == null || query.trim().length() == 0) { + if (query.trim().length() == 0) { queryString = Optional.empty(); } else { queryString = Optional.of("?" + query); @@ -133,7 +133,6 @@ private void parseUrl() { tmpfsOptions = parseTmpfsOptions(containerParameters); - initScriptPath = Optional.ofNullable(containerParameters.get("TC_INITSCRIPT")); reusable = Boolean.parseBoolean(containerParameters.get("TC_REUSABLE")); @@ -145,29 +144,26 @@ private void parseUrl() { 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")){ + if (!containerParameters.containsKey("TC_TMPFS")) { return Collections.emptyMap(); } String tmpfsOptions = containerParameters.get("TC_TMPFS"); - return Stream.of(tmpfsOptions.split(",")) - .collect(toMap( - string -> string.split(":")[0], - string -> string.split(":")[1])); + 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. + * 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()); @@ -181,19 +177,19 @@ private Map parseContainerParameters() { } /** - * Get all Query parameters specified in the Connection URL after ?. This DOES NOT include TestContainers (TC_*) parameters. + * 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)) + if (!key.matches(Patterns.TC_PARAM_NAME_PATTERN)) { results.put(key, value); + } } return results; @@ -205,37 +201,71 @@ public Map getTmpfsOptions() { /** * This interface defines the Regex Patterns used by {@link ConnectionUrl}. - * - * @author manikmagar */ 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:@([^\\?]+)(\\?.*)?"); + 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]+))?/([^\\\\?]+)"); - - Pattern DAEMON_MATCHING_PATTERN = Pattern.compile(".*([\\?&]?)TC_DAEMON=([^\\?&]+).*"); - Pattern INITSCRIPT_MATCHING_PATTERN = Pattern.compile(".*([\\?&]?)TC_INITSCRIPT=([^\\?&]+).*"); - Pattern INITFUNCTION_MATCHING_PATTERN = Pattern.compile(".*([\\?&]?)TC_INITFUNCTION=" + + 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("([^\\?&=]+)=([^\\?&]*)"); + 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 3e1f67c5b86..4f245590f78 100644 --- a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionWrapper.java +++ b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionWrapper.java @@ -17,7 +17,7 @@ public void close() throws SQLException { super.close(); try { closeCallback.run(); - } catch(Exception e) { + } catch (Exception e) { e.printStackTrace(); } } 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 c5413fe7efb..8f5f5f5a425 100644 --- a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ContainerDatabaseDriver.java +++ b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ContainerDatabaseDriver.java @@ -7,16 +7,27 @@ 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.nio.charset.StandardCharsets; -import java.sql.*; -import java.util.*; +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 javax.script.ScriptException; + /** * Test Containers JDBC proxy driver. This driver will handle JDBC URLs of the form: *

@@ -38,9 +49,13 @@ public class ContainerDatabaseDriver implements Driver { 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 { @@ -62,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. */ @@ -73,7 +87,6 @@ public synchronized Connection connect(String url, final Properties info) throws ConnectionUrl connectionUrl = ConnectionUrl.newInstance(url); synchronized (jdbcUrlContainerCache) { - String queryString = connectionUrl.getQueryString().orElse(""); /* If we already have a running container for this exact connection string, we want to connect @@ -81,7 +94,6 @@ public synchronized Connection connect(String url, final Properties info) throws */ JdbcDatabaseContainer container = jdbcUrlContainerCache.get(connectionUrl.getUrl()); if (container == null) { - LOGGER.debug("Container not found in cache, creating new instance"); Map parameters = connectionUrl.getContainerParameters(); @@ -89,7 +101,9 @@ public synchronized Connection connect(String url, final Properties info) throws /* 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(connectionUrl.getDatabaseType())) { container = candidateContainerType.newInstance(connectionUrl); @@ -98,7 +112,9 @@ public synchronized Connection connect(String url, final Properties info) throws } } if (container == null) { - throw new UnsupportedOperationException("Database name " + connectionUrl.getDatabaseType() + " not supported"); + throw new UnsupportedOperationException( + "Database name " + connectionUrl.getDatabaseType() + " not supported" + ); } /* @@ -121,7 +137,7 @@ 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 @@ -138,7 +154,6 @@ public synchronized Connection connect(String url, final Properties info) throws } } - /** * Wrap the connection, setting up a callback to be called when the connection is closed. *

@@ -149,23 +164,34 @@ public synchronized Connection connect(String url, final Properties info) throws * @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 ConnectionUrl connectionUrl) { - + 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<>()); + Set connections = containerConnections.computeIfAbsent( + container.getContainerId(), + k -> new HashSet<>() + ); connections.add(connection); final Set finalConnections = connections; - return new ConnectionWrapper(connection, () -> { - finalConnections.remove(connection); - if (!isDaemon && finalConnections.isEmpty()) { - container.stop(); - jdbcUrlContainerCache.remove(connectionUrl.getUrl()); + return new ConnectionWrapper( + connection, + () -> { + finalConnections.remove(connection); + if (!isDaemon && finalConnections.isEmpty()) { + synchronized (jdbcUrlContainerCache) { + container.stop(); + jdbcUrlContainerCache.remove(connectionUrl.getUrl()); + } + } } - }); + ); } /** @@ -175,7 +201,8 @@ private Connection wrapConnection(final Connection connection, final JdbcDatabas * @param databaseDelegate database delegate to apply init scripts to the database * @throws SQLException on script or DB error */ - private void runInitScriptIfRequired(final ConnectionUrl connectionUrl, DatabaseDelegate databaseDelegate) throws SQLException { + private void runInitScriptIfRequired(final ConnectionUrl connectionUrl, DatabaseDelegate databaseDelegate) + throws SQLException { if (connectionUrl.getInitScriptPath().isPresent()) { String initScriptPath = connectionUrl.getInitScriptPath().get(); try { @@ -189,7 +216,9 @@ private void runInitScriptIfRequired(final ConnectionUrl connectionUrl, Database } 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."); + throw new SQLException( + "Could not load classpath init script: " + initScriptPath + ". Resource not found." + ); } String sql = IOUtils.toString(resource, StandardCharsets.UTF_8); @@ -211,7 +240,8 @@ private void runInitScriptIfRequired(final ConnectionUrl connectionUrl, Database * @param connection JDBC connection to apply init functions to. * @throws SQLException on script or DB error */ - private void runInitFunctionIfRequired(final ConnectionUrl connectionUrl, Connection connection) throws SQLException { + 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(); @@ -221,7 +251,9 @@ private void runInitFunctionIfRequired(final ConnectionUrl connectionUrl, Connec 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); } @@ -230,27 +262,30 @@ private void runInitFunctionIfRequired(final ConnectionUrl connectionUrl, Connec @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"); } /** @@ -265,7 +300,6 @@ public static void killContainers() { containerConnections.clear(); initializedContainers.clear(); } - } /** diff --git a/modules/jdbc/src/main/java/org/testcontainers/jdbc/JdbcDatabaseDelegate.java b/modules/jdbc/src/main/java/org/testcontainers/jdbc/JdbcDatabaseDelegate.java index 6173a8f6a37..24c18944292 100644 --- a/modules/jdbc/src/main/java/org/testcontainers/jdbc/JdbcDatabaseDelegate.java +++ b/modules/jdbc/src/main/java/org/testcontainers/jdbc/JdbcDatabaseDelegate.java @@ -6,18 +6,20 @@ 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 - * - * @author Eugeny Karpov */ @Slf4j public class JdbcDatabaseDelegate extends AbstractDatabaseDelegate { private JdbcDatabaseContainer container; + + private Connection connection; + private String queryString; public JdbcDatabaseDelegate(JdbcDatabaseContainer container, String queryString) { @@ -28,23 +30,35 @@ public JdbcDatabaseDelegate(JdbcDatabaseContainer container, String queryString) @Override protected Statement createNewConnection() { try { - return container.createConnection(queryString).createStatement(); + 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) { + 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); + 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); } @@ -55,6 +69,7 @@ public void execute(String statement, String scriptPath, int lineNumber, boolean 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 76b7a044c9d..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 @@ -18,10 +18,11 @@ import org.testcontainers.jdbc.ContainerLessJdbcDelegate; -import javax.script.ScriptException; import java.sql.Connection; import java.util.List; +import javax.script.ScriptException; + /** * Wrapper for database-agnostic ScriptUtils * @@ -68,9 +69,24 @@ private ScriptUtils() { * @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); + 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 + ); } /** @@ -85,18 +101,40 @@ public static boolean containsSqlScriptDelimiters(String script, String delim) { * @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); + public static void executeSqlScript(Connection connection, String scriptPath, String script) + throws ScriptException { + org.testcontainers.ext.ScriptUtils.executeDatabaseScript( + new ContainerLessJdbcDelegate(connection), + scriptPath, + script + ); } /** * @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); + 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 + ); } -} \ No newline at end of file +} 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 index 3f0673d834f..5797ee0083c 100644 --- a/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlDriversTests.java +++ b/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlDriversTests.java @@ -1,57 +1,130 @@ package org.testcontainers.jdbc; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.junit.runners.Parameterized.Parameter; +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 java.util.Arrays.asList; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; /** * This Test class validates that all supported JDBC URL's can be parsed by ConnectionUrl class. - * - * @author ManikMagar */ -@RunWith(Parameterized.class) -public class ConnectionUrlDriversTests { +class ConnectionUrlDriversTests { - @Parameter - public String jdbcUrl; - @Parameter(1) - public String databaseType; - @Parameter(2) - public Optional tag; - @Parameter(3) - public String dbHostString; - @Parameter(4) - public String databaseName; - - @Parameterized.Parameters(name = "{index} - {0}") - public static Iterable data() { - return asList( - new Object[][]{ - {"jdbc:tc:mysql:5.5.43://hostname/test", "mysql", Optional.of("5.5.43"), "hostname/test", "test"}, - {"jdbc:tc:mysql://hostname/test", "mysql", Optional.empty(), "hostname/test", "test"}, - {"jdbc:tc:postgresql:1.2.3://hostname/test", "postgresql", Optional.of("1.2.3"), "hostname/test", "test"}, - {"jdbc:tc:postgresql://hostname/test", "postgresql", Optional.empty(), "hostname/test", "test"}, - {"jdbc:tc:sqlserver:1.2.3://localhost;instance=SQLEXPRESS:1433;databaseName=test", "sqlserver", Optional.of("1.2.3"), "localhost;instance=SQLEXPRESS:1433;databaseName=test", ""}, - {"jdbc:tc:sqlserver://localhost;instance=SQLEXPRESS:1433;databaseName=test", "sqlserver", Optional.empty(), "localhost;instance=SQLEXPRESS:1433;databaseName=test", ""}, - {"jdbc:tc:mariadb:1.2.3://localhost:3306/test", "mariadb", Optional.of("1.2.3"), "localhost:3306/test", "test"}, - {"jdbc:tc:mariadb://localhost:3306/test", "mariadb", Optional.empty(), "localhost:3306/test", "test"}, - {"jdbc:tc:oracle:1.2.3:thin:@localhost:1521/test", "oracle", Optional.of("1.2.3"), "localhost:1521/test", "test"}, - {"jdbc:tc:oracle:thin:@localhost:1521/test", "oracle", Optional.empty(), "localhost:1521/test", "test"} - }); + 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" + ) + ); } - @Test - public void test() { + @ParameterizedTest(name = "{index} - {0}") + @MethodSource("data") + void test(String jdbcUrl, String databaseType, Optional tag, String dbHostString, String databaseName) { ConnectionUrl url = ConnectionUrl.newInstance(jdbcUrl); - assertEquals("Database Type is as expected", databaseType, url.getDatabaseType()); - assertEquals("Image tag is as expected", tag, url.getImageTag()); - assertEquals("Database Host String is as expected", dbHostString, url.getDbHostString()); - assertEquals("Database Name is as expected", databaseName, url.getDatabaseName().orElse("")); + 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 index 312d5cf5146..16abff74ec9 100644 --- a/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlTest.java +++ b/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlTest.java @@ -1,109 +1,127 @@ package org.testcontainers.jdbc; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; +import org.junit.jupiter.api.Test; -import java.util.Optional; - -import static org.rnorth.visibleassertions.VisibleAssertions.*; - -public class ConnectionUrlTest { - - @Rule - public ExpectedException thrown = ExpectedException.none(); +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +class ConnectionUrlTest { @Test - public void testConnectionUrl1() { - String urlString = "jdbc:tc:mysql:5.6.23://somehostname:3306/databasename?a=b&c=d"; + void testConnectionUrl1() { + String urlString = "jdbc:tc:mysql:8.0.36://somehostname:3306/databasename?a=b&c=d"; ConnectionUrl url = ConnectionUrl.newInstance(urlString); - assertEquals("Database Type value is as expected", "mysql", url.getDatabaseType()); - assertEquals("Database Image tag value is as expected", "5.6.23", url.getImageTag().get()); - assertEquals("Database Host String is as expected", "somehostname:3306/databasename", url.getDbHostString()); - assertEquals("Query String value is as expected", "?a=b&c=d", url.getQueryString().get()); - assertEquals("Database Host value is as expected", "somehostname", url.getDatabaseHost().get()); - assertEquals("Database Port value is as expected", 3306, url.getDatabasePort().get()); - assertEquals("Database Name value is as expected", "databasename", url.getDatabaseName().get()); - - assertEquals("Parameter a is captured", "b", url.getQueryParameters().get("a")); - assertEquals("Parameter c is captured", "d", url.getQueryParameters().get("c")); + 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 - public void testConnectionUrl2() { + void testConnectionUrl2() { String urlString = "jdbc:tc:mysql://somehostname/databasename"; ConnectionUrl url = ConnectionUrl.newInstance(urlString); - assertEquals("Database Type value is as expected", "mysql", url.getDatabaseType()); - assertFalse("Database Image tag value is as expected", url.getImageTag().isPresent()); - assertEquals("Database Host String is as expected", "somehostname/databasename", url.getDbHostString()); - assertEquals("Query String value is as expected", Optional.empty(), url.getQueryString()); - assertEquals("Database Host value is as expected", "somehostname", url.getDatabaseHost().get()); - assertFalse("Database Port is null as expected", url.getDatabasePort().isPresent()); - assertEquals("Database Name value is as expected", "databasename", url.getDatabaseName().get()); - - assertTrue("Connection Parameters set is empty", url.getQueryParameters().isEmpty()); - + 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 - public void testEmptyQueryParameter() { + void testEmptyQueryParameter() { ConnectionUrl url = ConnectionUrl.newInstance("jdbc:tc:mysql://somehostname/databasename?key="); - assertEquals("'key' property value", "", url.getQueryParameters().get("key")); + assertThat(url.getQueryParameters().get("key")).as("'key' property value").isEqualTo(""); } @Test - public void testTmpfsOption() { + void testTmpfsOption() { String urlString = "jdbc:tc:mysql://somehostname/databasename?TC_TMPFS=key:value,key1:value1"; ConnectionUrl url = ConnectionUrl.newInstance(urlString); - assertTrue("Connection Parameters set is empty", url.getQueryParameters().isEmpty()); - assertFalse("Container Parameters set is not empty", url.getContainerParameters().isEmpty()); - assertEquals("Container Parameter TC_TMPFS is true", "key:value,key1:value1", url.getContainerParameters().get("TC_TMPFS")); - assertTrue("tmpfs option key exists", url.getTmpfsOptions().containsKey("key")); - assertEquals("tmpfs option key has correct value", "value" , url.getTmpfsOptions().get("key")); - assertTrue("tmpfs option key1 exists", url.getTmpfsOptions().containsKey("key1")); - assertEquals("tmpfs option key1 has correct value", "value1" , url.getTmpfsOptions().get("key1")); + 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 - public void testInitScriptPathCapture() { - String urlString = "jdbc:tc:mysql:5.6.23://somehostname:3306/databasename?a=b&c=d&TC_INITSCRIPT=somepath/init_mysql.sql"; + 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); - assertEquals("Database Type value is as expected", "somepath/init_mysql.sql", url.getInitScriptPath().get()); - assertEquals("Query String value is as expected", "?a=b&c=d", url.getQueryString().get()); - assertEquals("INIT SCRIPT Path exists in Container Parameters", "somepath/init_mysql.sql", url.getContainerParameters().get("TC_INITSCRIPT")); + 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 - thrown.expect(UnsupportedOperationException.class); - url.getContainerParameters().remove("TC_INITSCRIPT"); - url.getQueryParameters().remove("a"); - + assertThatThrownBy(() -> url.getContainerParameters().remove("TC_INITSCRIPT")) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> url.getQueryParameters().remove("a")) + .isInstanceOf(UnsupportedOperationException.class); } @Test - public void testInitFunctionCapture() { - String urlString = "jdbc:tc:mysql:5.6.23://somehostname:3306/databasename?a=b&c=d&TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction"; + 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); - assertTrue("Init Function parameter exists", url.getInitFunction().isPresent()); + 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"); + } - assertEquals("Init function class is as expected", "org.testcontainers.jdbc.JDBCDriverTest", url.getInitFunction().get().getClassName()); - assertEquals("Init function class is as expected", "sampleInitFunction", url.getInitFunction().get().getMethodName()); + @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 - public void testDaemonCapture() { - String urlString = "jdbc:tc:mysql:5.6.23://somehostname:3306/databasename?a=b&c=d&TC_DAEMON=true"; + void testHostLessConnectionUrl() { + String urlString = "jdbc:tc:mysql:8.0.36:///databasename?a=b&c=d"; ConnectionUrl url = ConnectionUrl.newInstance(urlString); - assertTrue("Daemon flag is set to true.", url.isInDaemonMode()); + 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 index 88398cebf85..469234e15a6 100644 --- a/modules/jdbc/src/test/java/org/testcontainers/jdbc/MissingJdbcDriverTest.java +++ b/modules/jdbc/src/test/java/org/testcontainers/jdbc/MissingJdbcDriverTest.java @@ -1,7 +1,7 @@ package org.testcontainers.jdbc; import com.google.common.base.Throwables; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.testcontainers.containers.JdbcDatabaseContainer; import org.testcontainers.utility.DockerImageName; @@ -9,15 +9,13 @@ import java.sql.SQLException; import java.util.concurrent.atomic.AtomicInteger; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.rnorth.visibleassertions.VisibleAssertions.fail; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; -public class MissingJdbcDriverTest { +class MissingJdbcDriverTest { @Test - public void shouldFailFastIfNoDriverFound() { - + void shouldFailFastIfNoDriverFound() { final MissingDriverContainer container = new MissingDriverContainer(); try { @@ -25,22 +23,27 @@ public void shouldFailFastIfNoDriverFound() { fail("The container is expected to fail to start"); } catch (Exception e) { final Throwable rootCause = Throwables.getRootCause(e); - assertTrue("ClassNotFoundException is the root cause", rootCause instanceof ClassNotFoundException); + assertThat(rootCause) + .as("ClassNotFoundException is the root cause") + .isInstanceOf(ClassNotFoundException.class); } finally { container.stop(); } - assertEquals("only one connection attempt should have been made", 1, container.getConnectionAttempts()); + 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:5.7.22")); + super(DockerImageName.parse("mysql:8.0.36")); withEnv("MYSQL_ROOT_PASSWORD", "test"); withExposedPorts(3306); } diff --git a/modules/jdbc/src/test/resources/logback-test.xml b/modules/jdbc/src/test/resources/logback-test.xml index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/jdbc/src/test/resources/logback-test.xml +++ b/modules/jdbc/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + diff --git a/modules/junit-jupiter/build.gradle b/modules/junit-jupiter/build.gradle index 12d4321d10f..b9ac700cea4 100644 --- a/modules/junit-jupiter/build.gradle +++ b/modules/junit-jupiter/build.gradle @@ -1,28 +1,19 @@ description = "Testcontainers :: JUnit Jupiter Extension" dependencies { - compile project(':testcontainers') - compile 'org.junit.jupiter:junit-jupiter-api:5.7.0' + api project(':testcontainers') + implementation platform('org.junit:junit-bom:5.14.3') + implementation 'org.junit.jupiter:junit-jupiter-api' - testCompile project(':mysql') - testCompile project(':postgresql') - testCompile 'com.zaxxer:HikariCP:3.4.5' - testCompile 'redis.clients:jedis:3.3.0' - testCompile 'org.apache.httpcomponents:httpclient:4.5.13' - testCompile ('org.mockito:mockito-core:3.6.28') { + 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') } - testCompile 'org.assertj:assertj-core:3.18.1' - testCompile 'org.junit.jupiter:junit-jupiter-params:5.7.0' - testRuntime 'org.postgresql:postgresql:42.2.18' - testRuntime 'mysql:mysql-connector-java:8.0.22' - testRuntime 'org.junit.jupiter:junit-jupiter-engine:5.7.0' -} - -test { - useJUnitPlatform() - testLogging { - events "passed", "skipped", "failed" - } + 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 index a075786d0d5..a22c23e57a6 100644 --- 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 @@ -11,7 +11,7 @@ * * @see Testcontainers */ -@Target(ElementType.FIELD) +@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 index ef8560b3f19..fe5ddcf32e0 100644 --- 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 @@ -4,19 +4,18 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.junit.platform.commons.util.StringUtils.isBlank; +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 (isBlank(contextId)) + return (contextId == null || contextId.trim().isEmpty()) ? UNKNOWN_NAME - : URLEncoder.encode(contextId, UTF_8.toString()); + : 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 index db8273563a7..8f9915ab334 100644 --- 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 @@ -12,7 +12,7 @@ * {@code @Testcontainers} is a JUnit Jupiter extension to activate automatic * startup and stop of containers used in a test case. * - *

The test containers extension finds all fields that are annotated with + *

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 @@ -59,9 +59,16 @@ @ExtendWith(TestcontainersExtension.class) @Inherited public @interface Testcontainers { - /** - * Whether tests should be disabled (rather than failing) when Docker is not available. + * 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 index 1ed5e954e44..89adba6033f 100644 --- 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 @@ -12,62 +12,78 @@ 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.jupiter.api.extension.TestInstancePostProcessor; import org.junit.platform.commons.support.AnnotationSupport; -import org.junit.platform.commons.util.AnnotationUtils; -import org.junit.platform.commons.util.Preconditions; -import org.junit.platform.commons.util.ReflectionUtils; -import org.testcontainers.DockerClientFactory; +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.lang.reflect.Method; +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; -import static java.util.stream.Collectors.toList; -import static org.testcontainers.junit.jupiter.FilesystemFriendlyNameGenerator.filesystemFriendlyNameOf; - -class TestcontainersExtension implements BeforeEachCallback, BeforeAllCallback, AfterEachCallback, AfterAllCallback, ExecutionCondition, TestInstancePostProcessor { +public class TestcontainersExtension + implements BeforeEachCallback, BeforeAllCallback, AfterEachCallback, AfterAllCallback, ExecutionCondition { private static final Namespace NAMESPACE = Namespace.create(TestcontainersExtension.class); - private static final String TEST_INSTANCE = "testInstance"; private static final String SHARED_LIFECYCLE_AWARE_CONTAINERS = "sharedLifecycleAwareContainers"; + private static final String LOCAL_LIFECYCLE_AWARE_CONTAINERS = "localLifecycleAwareContainers"; - @Override - public void postProcessTestInstance(final Object testInstance, final ExtensionContext context) { - Store store = context.getStore(NAMESPACE); - store.put(TEST_INSTANCE, testInstance); - } + private final DockerAvailableDetector dockerDetector = new DockerAvailableDetector(); @Override public void beforeAll(ExtensionContext context) { - Class testClass = context.getTestClass() - .orElseThrow(() -> new ExtensionConfigurationException("TestcontainersExtension is only supported for classes.")); + Class testClass = context + .getTestClass() + .orElseThrow(() -> { + return new ExtensionConfigurationException("TestcontainersExtension is only supported for classes."); + }); Store store = context.getStore(NAMESPACE); List sharedContainersStoreAdapters = findSharedContainers(testClass); - sharedContainersStoreAdapters.forEach(adapter -> store.getOrComputeIfAbsent(adapter.getKey(), k -> adapter.start())); + startContainers(sharedContainersStoreAdapters, store, context); List lifecycleAwareContainers = sharedContainersStoreAdapters .stream() .filter(this::isTestLifecycleAware) .map(lifecycleAwareAdapter -> (TestLifecycleAware) lifecycleAwareAdapter.container) - .collect(toList()); + .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); @@ -77,29 +93,55 @@ public void afterAll(ExtensionContext context) { public void beforeEach(final ExtensionContext context) { Store store = context.getStore(NAMESPACE); - List lifecycleAwareContainers = collectParentTestInstances(context).parallelStream() + List restartContainers = collectParentTestInstances(context) + .parallelStream() .flatMap(this::findRestartContainers) - .peek(adapter -> store.getOrComputeIfAbsent(adapter.getKey(), k -> adapter.start())) - .filter(this::isTestLifecycleAware) - .map(lifecycleAwareAdapter -> (TestLifecycleAware) lifecycleAwareAdapter.container) - .collect(toList()); + .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) { + 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); + List lifecycleAwareContainers = (List) context + .getStore(NAMESPACE) + .get(storeKey); if (lifecycleAwareContainers != null) { TestDescription description = testDescriptionFrom(context); Optional throwable = context.getExecutionException(); @@ -110,7 +152,7 @@ private void signalAfterTestToContainersFor(String storeKey, ExtensionContext co private TestDescription testDescriptionFrom(ExtensionContext context) { return new TestcontainersTestDescription( context.getUniqueId(), - filesystemFriendlyNameOf(context) + FilesystemFriendlyNameGenerator.filesystemFriendlyNameOf(context) ); } @@ -120,14 +162,18 @@ private boolean isTestLifecycleAware(StoreAdapter adapter) { @Override public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { - return findTestcontainers(context).map(this::evaluate) + 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 = AnnotationUtils.findAnnotation(current.get().getRequiredTestClass(), Testcontainers.class); + Optional testcontainers = AnnotationSupport.findAnnotation( + current.get().getRequiredTestClass(), + Testcontainers.class + ); if (testcontainers.isPresent()) { return testcontainers; } @@ -147,53 +193,36 @@ private ConditionEvaluationResult evaluate(Testcontainers testcontainers) { } boolean isDockerAvailable() { - try { - DockerClientFactory.instance().client(); - return true; - } catch (Throwable ex) { - return false; - } + return this.dockerDetector.isDockerAvailable(); } private Set collectParentTestInstances(final ExtensionContext context) { - Set testInstances = new LinkedHashSet<>(); - Optional current = Optional.of(context); - while (current.isPresent()) { - ExtensionContext ctx = current.get(); - Object testInstance = ctx.getStore(NAMESPACE).remove(TEST_INSTANCE); - if (testInstance != null) { - testInstances.add(testInstance); - } - current = ctx.getParent(); - } - return testInstances; + List allInstances = new ArrayList<>(context.getRequiredTestInstances().getAllInstances()); + Collections.reverse(allInstances); + return new LinkedHashSet<>(allInstances); } private List findSharedContainers(Class testClass) { - return ReflectionUtils.findFields( - testClass, - isSharedContainer(), - ReflectionUtils.HierarchyTraversalMode.TOP_DOWN) + return ReflectionSupport + .findFields(testClass, isSharedContainer(), HierarchyTraversalMode.TOP_DOWN) .stream() .map(f -> getContainerInstance(null, f)) - .collect(toList()); + .collect(Collectors.toList()); } private Predicate isSharedContainer() { - return isContainer().and(ReflectionUtils::isStatic); + return isContainer().and(ModifierSupport::isStatic); } private Stream findRestartContainers(Object testInstance) { - return ReflectionUtils.findFields( - testInstance.getClass(), - isRestartContainer(), - ReflectionUtils.HierarchyTraversalMode.TOP_DOWN) + return ReflectionSupport + .findFields(testInstance.getClass(), isRestartContainer(), HierarchyTraversalMode.TOP_DOWN) .stream() .map(f -> getContainerInstance(testInstance, f)); } private Predicate isRestartContainer() { - return isContainer().and(ReflectionUtils::isNotStatic); + return isContainer().and(ModifierSupport::isNotStatic); } private static Predicate isContainer() { @@ -203,7 +232,9 @@ private static Predicate isContainer() { boolean isStartable = Startable.class.isAssignableFrom(field.getType()); if (!isStartable) { - throw new ExtensionConfigurationException(String.format("FieldName: %s does not implement Startable", field.getName())); + throw new ExtensionConfigurationException( + String.format("FieldName: %s does not implement Startable", field.getName()) + ); } return true; } @@ -214,7 +245,10 @@ private static Predicate isContainer() { private static StoreAdapter getContainerInstance(final Object testInstance, final Field field) { try { field.setAccessible(true); - Startable containerInstance = Preconditions.notNull((Startable) field.get(testInstance), "Container " + field.getName() + " needs to be initialized"); + 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()); @@ -226,7 +260,7 @@ private static StoreAdapter getContainerInstance(final Object testInstance, fina * thereby letting the JUnit automatically stop containers once the current * {@link ExtensionContext} is closed. */ - private static class StoreAdapter implements CloseableResource { + private static class StoreAdapter implements CloseableResource, AutoCloseable { @Getter private String key; diff --git a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainerTestDescription.java b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainersTestDescription.java similarity index 99% rename from modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainerTestDescription.java rename to modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainersTestDescription.java index 568d228e08c..3cf466a5708 100644 --- a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainerTestDescription.java +++ b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainersTestDescription.java @@ -5,6 +5,8 @@ @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 index 11b9027b392..a60b24a23d2 100644 --- 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 @@ -4,39 +4,30 @@ 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.BeforeEach; import org.junit.jupiter.api.Test; -import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.ComposeContainer; import org.testcontainers.containers.wait.strategy.Wait; import java.io.File; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; @Testcontainers class ComposeContainerTests { @Container - private DockerComposeContainer composeContainer = new DockerComposeContainer( - new File("src/test/resources/docker-compose.yml")) - .withExposedService("whoami_1", 80, Wait.forHttp("/")); - - private String host; - - private int port; - - @BeforeEach - void setup() { - host = composeContainer.getServiceHost("whoami_1", 80); - port = composeContainer.getServicePort("whoami_1", 80); - } + 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)); - assertEquals(200, response.getStatusLine().getStatusCode()); + 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 index 96617b0e063..9050df6f257 100644 --- 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 @@ -10,7 +10,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; -import static org.testcontainers.junit.jupiter.FilesystemFriendlyNameGenerator.filesystemFriendlyNameOf; class FilesystemFriendlyNameGeneratorTest { @@ -18,10 +17,9 @@ class FilesystemFriendlyNameGeneratorTest { @MethodSource("provideDisplayNamesAndFilesystemFriendlyNames") void should_generate_filesystem_friendly_name(String displayName, String expectedName) { ExtensionContext context = mock(ExtensionContext.class); - doReturn(displayName) - .when(context).getUniqueId(); + doReturn(displayName).when(context).getUniqueId(); - String filesystemFriendlyName = filesystemFriendlyNameOf(context); + String filesystemFriendlyName = FilesystemFriendlyNameGenerator.filesystemFriendlyNameOf(context); assertThat(filesystemFriendlyName).isEqualTo(expectedName); } 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 index e99d1ec720c..4343b5dffc8 100644 --- 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 @@ -4,5 +4,8 @@ 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 index 791c887cc19..7dcd769bf08 100644 --- 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 @@ -1,18 +1,10 @@ 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.BeforeEach; import org.junit.jupiter.api.Test; -import org.testcontainers.containers.DockerComposeContainer; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.containers.wait.strategy.Wait; - -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; // testClass { @Testcontainers @@ -20,19 +12,19 @@ class MixedLifecycleTests { // will be shared between test methods @Container - private static final MySQLContainer MY_SQL_CONTAINER = new MySQLContainer(); + 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() + private PostgreSQLContainer postgresqlContainer = new PostgreSQLContainer("postgres:9.6.12") .withDatabaseName("foo") .withUsername("foo") .withPassword("secret"); @Test void test() { - assertTrue(MY_SQL_CONTAINER.isRunning()); - assertTrue(postgresqlContainer.isRunning()); + 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 index 4cbc331e64b..ee6d1f1a841 100644 --- 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 @@ -8,17 +8,18 @@ import java.sql.ResultSet; import java.sql.Statement; -import static org.junit.Assert.assertEquals; -import static org.testcontainers.junit.jupiter.JUnitJupiterTestImages.POSTGRES_IMAGE; +import static org.assertj.core.api.Assertions.assertThat; @Testcontainers class PostgresContainerTests { @Container - private static final PostgreSQLContainer POSTGRE_SQL_CONTAINER = new PostgreSQLContainer<>(POSTGRES_IMAGE) - .withDatabaseName("foo") - .withUsername("foo") - .withPassword("secret"); + 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 { @@ -34,8 +35,7 @@ void waits_until_postgres_accepts_jdbc_connections() throws Exception { resultSet.next(); int resultSetInt = resultSet.getInt(1); - assertEquals(1, resultSetInt); + 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 index 38751c8789a..a8217d60840 100644 --- 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 @@ -11,9 +11,11 @@ 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; @@ -43,12 +45,8 @@ public List getLifecycleFilesystemFriendlyNames() { } @Override - public void start() { - - } + public void start() {} @Override - public void stop() { - - } + 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 index 85d837a7880..a6647aa3e52 100644 --- 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 @@ -1,19 +1,19 @@ package org.testcontainers.junit.jupiter; -import org.junit.AssumptionViolatedException; 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.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import static org.junit.Assume.assumeTrue; +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(); @@ -24,14 +24,14 @@ class TestLifecycleAwareExceptionCapturingTest { 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 - assumeTrue(false); + assumeThat(false).isTrue(); } @Test @Order(2) void should_have_captured_thrownException() { Throwable capturedThrowable = startedTestContainer.getCapturedThrowable(); - assertTrue(capturedThrowable instanceof AssumptionViolatedException); - assertEquals("got: , expected: is ", capturedThrowable.getMessage()); + 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 index cecdff490ef..a77242e1d3a 100644 --- 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 @@ -10,14 +10,13 @@ import org.junit.jupiter.api.extension.ExtensionContext; import static org.assertj.core.api.Assertions.assertThat; -import static org.testcontainers.junit.jupiter.TestLifecycleAwareContainerMock.AFTER_TEST; -import static org.testcontainers.junit.jupiter.TestLifecycleAwareContainerMock.BEFORE_TEST; // The order of @ExtendsWith and @Testcontainers is crucial for the tests -@ExtendWith({TestLifecycleAwareMethodTest.SharedContainerAfterAllTestExtension.class}) +@ExtendWith({ TestLifecycleAwareMethodTest.SharedContainerAfterAllTestExtension.class }) @Testcontainers @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class TestLifecycleAwareMethodTest { + @Container private final TestLifecycleAwareContainerMock testContainer = new TestLifecycleAwareContainerMock(); @@ -28,8 +27,8 @@ class TestLifecycleAwareMethodTest { @BeforeAll static void beforeAll() { - assertThat(SHARED_CONTAINER.getLifecycleMethodCalls()).containsExactly(BEFORE_TEST); - + assertThat(SHARED_CONTAINER.getLifecycleMethodCalls()) + .containsExactly(TestLifecycleAwareContainerMock.BEFORE_TEST); } @Test @@ -43,7 +42,7 @@ void should_prepare_before_and_after_test() { @Order(2) void should_call_beforeTest_first_afterTest_later_with_filesystem_friendly_name() { assertThat(startedTestContainer.getLifecycleMethodCalls()) - .containsExactly(BEFORE_TEST, AFTER_TEST); + .containsExactly(TestLifecycleAwareContainerMock.BEFORE_TEST, TestLifecycleAwareContainerMock.AFTER_TEST); } @Test @@ -63,11 +62,15 @@ void static_container_should_have_a_filesystem_friendly_name_after_container_has } 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(BEFORE_TEST, AFTER_TEST); + .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 index f3aea9bb850..8852524d451 100644 --- 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 @@ -4,8 +4,7 @@ import org.junit.jupiter.api.extension.ConditionEvaluationResult; import org.junit.jupiter.api.extension.ExtensionContext; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -15,28 +14,28 @@ public class TestcontainersExtensionTests { void whenDisabledWithoutDockerAndDockerIsAvailableTestsAreEnabled() { ConditionEvaluationResult result = new TestTestcontainersExtension(true) .evaluateExecutionCondition(extensionContext(DisabledWithoutDocker.class)); - assertFalse(result.isDisabled()); + assertThat(result.isDisabled()).isFalse(); } @Test void whenDisabledWithoutDockerAndDockerIsUnavailableTestsAreDisabled() { ConditionEvaluationResult result = new TestTestcontainersExtension(false) .evaluateExecutionCondition(extensionContext(DisabledWithoutDocker.class)); - assertTrue(result.isDisabled()); + assertThat(result.isDisabled()).isTrue(); } @Test void whenEnabledWithoutDockerAndDockerIsAvailableTestsAreEnabled() { ConditionEvaluationResult result = new TestTestcontainersExtension(true) .evaluateExecutionCondition(extensionContext(EnabledWithoutDocker.class)); - assertFalse(result.isDisabled()); + assertThat(result.isDisabled()).isFalse(); } @Test void whenEnabledWithoutDockerAndDockerIsUnavailableTestsAreEnabled() { ConditionEvaluationResult result = new TestTestcontainersExtension(false) .evaluateExecutionCondition(extensionContext(EnabledWithoutDocker.class)); - assertFalse(result.isDisabled()); + assertThat(result.isDisabled()).isFalse(); } private ExtensionContext extensionContext(Class clazz) { @@ -46,14 +45,10 @@ private ExtensionContext extensionContext(Class clazz) { } @Testcontainers(disabledWithoutDocker = true) - static final class DisabledWithoutDocker { - - } + static final class DisabledWithoutDocker {} @Testcontainers - static final class EnabledWithoutDocker { - - } + static final class EnabledWithoutDocker {} static final class TestTestcontainersExtension extends TestcontainersExtension { @@ -66,7 +61,5 @@ private TestTestcontainersExtension(boolean 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 index 6e02e31b774..f3d81a0dfcd 100644 --- 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 @@ -4,17 +4,16 @@ import org.junit.jupiter.api.Test; import org.testcontainers.containers.GenericContainer; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.testcontainers.junit.jupiter.JUnitJupiterTestImages.HTTPD_IMAGE; +import static org.assertj.core.api.Assertions.assertThat; // testClass { @Testcontainers class TestcontainersNestedRestartedContainerTests { @Container - private final GenericContainer topLevelContainer = new GenericContainer<>(HTTPD_IMAGE) + private final GenericContainer topLevelContainer = new GenericContainer<>(JUnitJupiterTestImages.HTTPD_IMAGE) .withExposedPorts(80); + // }} private static String topLevelContainerId; @@ -24,57 +23,57 @@ class TestcontainersNestedRestartedContainerTests { // testClass { @Test void top_level_container_should_be_running() { - assertTrue(topLevelContainer.isRunning()); -// }} + assertThat(topLevelContainer.isRunning()).isTrue(); + // }} topLevelContainerId = topLevelContainer.getContainerId(); -// testClass {{ + // testClass {{ } @Nested class NestedTestCase { @Container - private final GenericContainer nestedContainer = new GenericContainer<>(HTTPD_IMAGE) + 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 - assertTrue(topLevelContainer.isRunning()); + assertThat(topLevelContainer.isRunning()).isTrue(); // nested containers are only available inside their nested class - assertTrue(nestedContainer.isRunning()); -// }}} + assertThat(nestedContainer.isRunning()).isTrue(); + // }}} if (nestedContainerId == null) { nestedContainerId = nestedContainer.getContainerId(); } else { - assertNotEquals(nestedContainerId, nestedContainer.getContainerId()); + assertThat(nestedContainer.getContainerId()).isNotEqualTo(nestedContainerId); } -// testClass {{ + // testClass {{ } // } @Test void containers_should_not_be_the_same() { - assertNotEquals(topLevelContainer.getContainerId(), nestedContainer.getContainerId()); + assertThat(nestedContainer.getContainerId()).isNotEqualTo(topLevelContainer.getContainerId()); if (nestedContainerId == null) { nestedContainerId = nestedContainer.getContainerId(); } else { - assertNotEquals(nestedContainerId, nestedContainer.getContainerId()); + assertThat(nestedContainer.getContainerId()).isNotEqualTo(nestedContainerId); } } @Test void ids_should_not_change() { - assertNotEquals(topLevelContainerId, topLevelContainer.getContainerId()); + assertThat(topLevelContainer.getContainerId()).isNotEqualTo(topLevelContainerId); if (nestedContainerId == null) { nestedContainerId = nestedContainer.getContainerId(); } else { - assertNotEquals(nestedContainerId, nestedContainer.getContainerId()); + assertThat(nestedContainer.getContainerId()).isNotEqualTo(nestedContainerId); } } -// testClass {{{ + // 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 index cf59e0338a2..61a4df2c6ec 100644 --- 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 @@ -4,22 +4,22 @@ import org.junit.jupiter.api.Test; import org.testcontainers.containers.GenericContainer; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.testcontainers.junit.jupiter.JUnitJupiterTestImages.HTTPD_IMAGE; +import static org.assertj.core.api.Assertions.assertThat; @Testcontainers class TestcontainersNestedSharedContainerTests { @Container - private static final GenericContainer TOP_LEVEL_CONTAINER = new GenericContainer<>(HTTPD_IMAGE) + 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() { - assertTrue(TOP_LEVEL_CONTAINER.isRunning()); + assertThat(TOP_LEVEL_CONTAINER.isRunning()).isTrue(); topLevelContainerId = TOP_LEVEL_CONTAINER.getContainerId(); } @@ -28,12 +28,12 @@ class NestedTestCase { @Test void top_level_containers_should_be_running() { - assertTrue(TOP_LEVEL_CONTAINER.isRunning()); + assertThat(TOP_LEVEL_CONTAINER.isRunning()).isTrue(); } @Test void ids_should_not_change() { - assertEquals(topLevelContainerId, TOP_LEVEL_CONTAINER.getContainerId()); + 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 index eeb63b72380..53e6a0968a0 100644 --- 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 @@ -3,15 +3,14 @@ import org.junit.jupiter.api.Test; import org.testcontainers.containers.GenericContainer; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.testcontainers.junit.jupiter.JUnitJupiterTestImages.HTTPD_IMAGE; +import static org.assertj.core.api.Assertions.assertThat; @Testcontainers class TestcontainersRestartBetweenTests { @Container - private GenericContainer genericContainer = new GenericContainer<>(HTTPD_IMAGE) - .withExposedPorts(80); + private GenericContainer genericContainer = new GenericContainer<>(JUnitJupiterTestImages.HTTPD_IMAGE) + .withExposedPorts(80); private static String lastContainerId; @@ -19,8 +18,8 @@ class TestcontainersRestartBetweenTests { void first_test() { if (lastContainerId == null) { lastContainerId = genericContainer.getContainerId(); - } else { - assertNotEquals(lastContainerId, genericContainer.getContainerId()); + } else { + assertThat(genericContainer.getContainerId()).isNotEqualTo(lastContainerId); } } @@ -28,9 +27,8 @@ void first_test() { void second_test() { if (lastContainerId == null) { lastContainerId = genericContainer.getContainerId(); - } else { - assertNotEquals(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 index 60094994f69..9bd23e8d20e 100644 --- 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 @@ -4,22 +4,22 @@ import org.junit.jupiter.api.Test; import org.testcontainers.containers.GenericContainer; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.testcontainers.junit.jupiter.JUnitJupiterTestImages.HTTPD_IMAGE; +import static org.assertj.core.api.Assertions.assertThat; @Testcontainers class TestcontainersSharedContainerTests { @Container - private static final GenericContainer GENERIC_CONTAINER = new GenericContainer<>(HTTPD_IMAGE) + private static final GenericContainer GENERIC_CONTAINER = new GenericContainer<>( + JUnitJupiterTestImages.HTTPD_IMAGE + ) .withExposedPorts(80); private static String lastContainerId; @BeforeAll static void doSomethingWithAContainer() { - assertTrue(GENERIC_CONTAINER.isRunning()); + assertThat(GENERIC_CONTAINER.isRunning()).isTrue(); } @Test @@ -27,7 +27,7 @@ void first_test() { if (lastContainerId == null) { lastContainerId = GENERIC_CONTAINER.getContainerId(); } else { - assertEquals(lastContainerId, GENERIC_CONTAINER.getContainerId()); + assertThat(GENERIC_CONTAINER.getContainerId()).isEqualTo(lastContainerId); } } @@ -36,8 +36,7 @@ void second_test() { if (lastContainerId == null) { lastContainerId = GENERIC_CONTAINER.getContainerId(); } else { - assertEquals(lastContainerId, GENERIC_CONTAINER.getContainerId()); + 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 index bec99894307..9fe9893d9ee 100644 --- 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 @@ -1,10 +1,8 @@ package org.testcontainers.junit.jupiter; - import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; - @Disabled @Testcontainers class WrongAnnotationUsageTests { @@ -16,5 +14,4 @@ class WrongAnnotationUsageTests { 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 index 3766c3f7a60..70614d3dbc7 100644 --- 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 @@ -11,5 +11,4 @@ abstract class AbstractTestBase { @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 index 720c31cf794..f57fde6b419 100644 --- 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 @@ -3,7 +3,7 @@ import org.junit.jupiter.api.Test; import org.testcontainers.junit.jupiter.Container; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; class InheritedTests extends AbstractTestBase { @@ -12,15 +12,15 @@ class InheritedTests extends AbstractTestBase { @Test void step1() { - assertEquals(1, redisPerClass.getJedis().incr("key").longValue()); - assertEquals(1, redisPerTest.getJedis().incr("key").longValue()); - assertEquals(1, myRedis.getJedis().incr("key").longValue()); + assertThat(redisPerClass.getJedis().incr("key")).isEqualTo(1); + assertThat(redisPerTest.getJedis().incr("key")).isEqualTo(1); + assertThat(myRedis.getJedis().incr("key")).isEqualTo(1); } @Test void step2() { - assertEquals(2, redisPerClass.getJedis().incr("key").longValue()); - assertEquals(1, redisPerTest.getJedis().incr("key").longValue()); - assertEquals(1, myRedis.getJedis().incr("key").longValue()); + 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 index 0a9ec62fca2..f6ed39c483e 100644 --- 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 @@ -7,7 +7,7 @@ public class RedisContainer extends GenericContainer { public RedisContainer() { - super(DockerImageName.parse("redis:3.2.11")); + super(DockerImageName.parse("redis:6-alpine")); withExposedPorts(6379); } diff --git a/modules/junit-jupiter/src/test/resources/logback-test.xml b/modules/junit-jupiter/src/test/resources/logback-test.xml index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/junit-jupiter/src/test/resources/logback-test.xml +++ b/modules/junit-jupiter/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + 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 index 4454823bda4..aabad9553b0 100644 --- a/modules/kafka/build.gradle +++ b/modules/kafka/build.gradle @@ -1,9 +1,9 @@ description = "Testcontainers :: Kafka" dependencies { - compile project(':testcontainers') + api project(':testcontainers') - testCompile 'org.apache.kafka:kafka-clients:2.6.0' - testCompile 'org.assertj:assertj-core:3.18.1' - testCompile 'com.google.guava:guava:23.0' + 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 index b1bc3d735f9..7eb836ade18 100644 --- a/modules/kafka/src/main/java/org/testcontainers/containers/KafkaContainer.java +++ b/modules/kafka/src/main/java/org/testcontainers/containers/KafkaContainer.java @@ -1,24 +1,41 @@ package org.testcontainers.containers; import com.github.dockerjava.api.command.InspectContainerResponse; -import lombok.SneakyThrows; +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.nio.charset.StandardCharsets; +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; -import java.util.stream.Stream; /** - * This container wraps Confluent Kafka and Zookeeper (optionally) + * 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"; - private static final String STARTER_SCRIPT = "/testcontainers_start.sh"; + private static final String DEFAULT_TAG = "5.4.3"; public static final int KAFKA_PORT = 9093; @@ -26,14 +43,21 @@ public class KafkaContainer extends GenericContainer { private static final String DEFAULT_INTERNAL_TOPIC_RF = "1"; - private static final int PORT_NOT_ASSIGNED = -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 int port = PORT_NOT_ASSIGNED; + private boolean kraftEnabled = false; + + private static final String PROTOCOL_PREFIX = "TC"; /** - * @deprecated use {@link KafkaContainer(DockerImageName)} instead + * @deprecated use {@link #KafkaContainer(DockerImageName)} instead */ @Deprecated public KafkaContainer() { @@ -41,7 +65,7 @@ public KafkaContainer() { } /** - * @deprecated use {@link KafkaContainer(DockerImageName)} instead + * @deprecated use {@link #KafkaContainer(DockerImageName)} instead */ @Deprecated public KafkaContainer(String confluentPlatformVersion) { @@ -50,93 +74,287 @@ public KafkaContainer(String confluentPlatformVersion) { public KafkaContainer(final DockerImageName dockerImageName) { super(dockerImageName); - dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + } - withExposedPorts(KAFKA_PORT); - - // 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 - withEnv("KAFKA_LISTENERS", "PLAINTEXT://0.0.0.0:" + KAFKA_PORT + ",BROKER://0.0.0.0:9092"); - withEnv("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", "BROKER:PLAINTEXT,PLAINTEXT:PLAINTEXT"); - withEnv("KAFKA_INTER_BROKER_LISTENER_NAME", "BROKER"); + @Override + KafkaContainerDef createContainerDef() { + return new KafkaContainerDef(); + } - withEnv("KAFKA_BROKER_ID", "1"); - withEnv("KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR", DEFAULT_INTERNAL_TOPIC_RF); - withEnv("KAFKA_OFFSETS_TOPIC_NUM_PARTITIONS", DEFAULT_INTERNAL_TOPIC_RF); - withEnv("KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR", DEFAULT_INTERNAL_TOPIC_RF); - withEnv("KAFKA_TRANSACTION_STATE_LOG_MIN_ISR", DEFAULT_INTERNAL_TOPIC_RF); - withEnv("KAFKA_LOG_FLUSH_INTERVAL_MESSAGES", Long.MAX_VALUE + ""); - withEnv("KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS", "0"); + @Override + KafkaContainerDef getContainerDef() { + return (KafkaContainerDef) super.getContainerDef(); } public KafkaContainer withEmbeddedZookeeper() { - externalZookeeperConnect = null; + if (this.kraftEnabled) { + throw new IllegalStateException("Cannot configure Zookeeper when using Kraft mode"); + } + this.externalZookeeperConnect = null; return self(); } public KafkaContainer withExternalZookeeper(String connectString) { - externalZookeeperConnect = connectString; + if (this.kraftEnabled) { + throw new IllegalStateException("Cannot configure Zookeeper when using Kraft mode"); + } + this.externalZookeeperConnect = connectString; return self(); } - public String getBootstrapServers() { - if (port == PORT_NOT_ASSIGNED) { - throw new IllegalStateException("You should start Kafka container first"); + 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 + ) + ); } - return String.format("PLAINTEXT://%s:%s", getHost(), port); + } + + 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 doStart() { - withCommand("sh", "-c", "while [ ! -f " + STARTER_SCRIPT + " ]; do sleep 0.1; done; " + STARTER_SCRIPT); + protected void configure() { + getContainerDef().resolveListeners(); - if (externalZookeeperConnect == null) { - addExposedPort(ZOOKEEPER_PORT); + if (this.kraftEnabled) { + configureKraft(); + } else { + configureZookeeper(); } + } - super.doStart(); + protected void configureKraft() { + getContainerDef().withRaft(); + } + + protected void configureZookeeper() { + if (this.externalZookeeperConnect == null) { + getContainerDef().withEmbeddedZookeeper(); + } else { + getContainerDef().withZookeeper(this.externalZookeeperConnect); + } } @Override - @SneakyThrows - protected void containerIsStarting(InspectContainerResponse containerInfo, boolean reused) { - super.containerIsStarting(containerInfo, reused); + protected void containerIsStarting(InspectContainerResponse containerInfo) { + super.containerIsStarting(containerInfo); - port = getMappedPort(KAFKA_PORT); + List advertisedListeners = new ArrayList<>(); + advertisedListeners.add(getBootstrapServers()); + advertisedListeners.add(brokerAdvertisedListener(containerInfo)); - if (reused) { - return; + 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"; - final String zookeeperConnect; - if (externalZookeeperConnect != null) { - zookeeperConnect = externalZookeeperConnect; - } else { - zookeeperConnect = "localhost:" + ZOOKEEPER_PORT; - command += "echo 'clientPort=" + ZOOKEEPER_PORT + "' > zookeeper.properties\n"; - command += "echo 'dataDir=/var/lib/zookeeper/data' >> zookeeper.properties\n"; - command += "echo 'dataLogDir=/var/lib/zookeeper/log' >> zookeeper.properties\n"; - command += "zookeeper-server-start zookeeper.properties &\n"; - } - - command += "export KAFKA_ZOOKEEPER_CONNECT='" + zookeeperConnect + "'\n"; - command += "export KAFKA_ADVERTISED_LISTENERS='" + Stream - .concat( - Stream.of(getBootstrapServers()), - containerInfo.getNetworkSettings().getNetworks().values().stream() - .map(it -> "BROKER://" + it.getIpAddress() + ":9092") - ) - .collect(Collectors.joining(",")) + "'\n"; - - command += ". /etc/confluent/docker/bash-config \n"; - command += "/etc/confluent/docker/configure \n"; - command += "/etc/confluent/docker/launch \n"; - - copyFileToContainer( - Transferable.of(command.getBytes(StandardCharsets.UTF_8), 0777), - STARTER_SCRIPT - ); + // 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 index d219aa17b13..e244d7a2e26 100644 --- a/modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java +++ b/modules/kafka/src/test/java/org/testcontainers/containers/KafkaContainerTest.java @@ -1,84 +1,77 @@ package org.testcontainers.containers; -import static java.util.Collections.singletonList; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.tuple; - import com.google.common.collect.ImmutableMap; -import java.time.Duration; -import java.util.Collection; -import java.util.UUID; -import java.util.concurrent.TimeUnit; +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.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.junit.ClassRule; -import org.junit.Test; -import org.rnorth.ducttape.unreliables.Unreliables; +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; -public class KafkaContainerTest { +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.TimeUnit; - private static final DockerImageName KAFKA_TEST_IMAGE = DockerImageName.parse("confluentinc/cp-kafka:5.4.3"); - private static final DockerImageName ZOOKEEPER_TEST_IMAGE = DockerImageName.parse("confluentinc/cp-zookeeper:4.0.0"); +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"); - // junitRule { - @ClassRule - public static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:5.4.3")); - // } + private static final DockerImageName ZOOKEEPER_TEST_IMAGE = DockerImageName.parse( + "confluentinc/cp-zookeeper:4.0.0" + ); @Test - public void testUsage() throws Exception { + void testUsage() throws Exception { try (KafkaContainer kafka = new KafkaContainer(KAFKA_TEST_IMAGE)) { kafka.start(); testKafkaFunctionality(kafka.getBootstrapServers()); } } - @Test - public void testUsageWithSpecificImage() throws Exception { + void testUsageWithSpecificImage() throws Exception { try ( // constructorWithVersion { - KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:5.4.3")) + KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1")) // } ) { kafka.start(); testKafkaFunctionality( - // getBootstrapServers { - kafka.getBootstrapServers() - // } + // getBootstrapServers { + kafka.getBootstrapServers() + // } ); } } - @Test - public void testUsageWithVersion() throws Exception { - try ( - KafkaContainer kafka = new KafkaContainer("5.5.1") - ) { + void testUsageWithVersion() throws Exception { + try (KafkaContainer kafka = new KafkaContainer("6.2.1")) { kafka.start(); testKafkaFunctionality(kafka.getBootstrapServers()); } } @Test - public void testExternalZookeeperWithExternalNetwork() throws Exception { + void testExternalZookeeperWithExternalNetwork() throws Exception { try ( Network network = Network.newNetwork(); - // withExternalZookeeper { - KafkaContainer kafka = new KafkaContainer(KAFKA_TEST_IMAGE) + KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1")) .withNetwork(network) .withExternalZookeeper("zookeeper:2181"); // } @@ -87,87 +80,267 @@ public void testExternalZookeeperWithExternalNetwork() throws Exception { .withNetwork(network) .withNetworkAliases("zookeeper") .withEnv("ZOOKEEPER_CLIENT_PORT", "2181"); - - // withKafkaNetwork { - GenericContainer application = new GenericContainer<>(DockerImageName.parse("alpine")) - .withNetwork(network) - // } - .withNetworkAliases("dummy") - .withCommand("sleep 10000") ) { zookeeper.start(); kafka.start(); - application.start(); testKafkaFunctionality(kafka.getBootstrapServers()); } } @Test - public void testConfluentPlatformVersion6() throws Exception { + 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:6.0.0")) + KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.0.1")).withKraft() ) { kafka.start(); testKafkaFunctionality(kafka.getBootstrapServers()); } } - protected void testKafkaFunctionality(String bootstrapServers) throws Exception { - testKafkaFunctionality(bootstrapServers, 1, 1); + @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()); + } } - protected void testKafkaFunctionality(String bootstrapServers, int partitions, int rf) throws Exception { + @Test + void testNotSupportedKraftVersion() { try ( - AdminClient adminClient = AdminClient.create(ImmutableMap.of( - AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers - )); + 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)" + ); + } + } - KafkaProducer producer = new KafkaProducer<>( - ImmutableMap.of( - ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers, - ProducerConfig.CLIENT_ID_CONFIG, UUID.randomUUID().toString() - ), - new StringSerializer(), - new StringSerializer() - ); + @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"); + } - 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(); + 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"); + } - Collection topics = singletonList(new NewTopic(topicName, partitions, (short) rf)); - adminClient.createTopics(topics).all().get(30, TimeUnit.SECONDS); + 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"); + } + } - consumer.subscribe(singletonList(topicName)); + @Test + void testKraftPrecedenceOverEmbeddedZookeeper() throws Exception { + try (KafkaContainer kafka = new KafkaContainer(KAFKA_KRAFT_TEST_IMAGE).withEmbeddedZookeeper().withKraft()) { + kafka.start(); + testKafkaFunctionality(kafka.getBootstrapServers()); + } + } - producer.send(new ProducerRecord<>(topicName, "testcontainers", "rulezzz")).get(); + @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"); + } + } - Unreliables.retryUntilTrue(10, TimeUnit.SECONDS, () -> { - ConsumerRecords records = consumer.poll(Duration.ofMillis(100)); + @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(); - if (records.isEmpty()) { - return false; + 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(); - assertThat(records) - .hasSize(1) - .extracting(ConsumerRecord::topic, ConsumerRecord::key, ConsumerRecord::value) - .containsExactly(tuple(topicName, "testcontainers", "rulezzz")); + testSecureScramKafkaFunctionality(kafka.getBootstrapServers()); + } + } - return true; - }); + @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\";" + ) + ); - consumer.unsubscribe(); + 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 index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/kafka/src/test/resources/logback-test.xml +++ b/modules/kafka/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + 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 index fdffc649a72..eca4aeaa27a 100644 --- a/modules/localstack/build.gradle +++ b/modules/localstack/build.gradle @@ -1,11 +1,12 @@ description = "Testcontainers :: Localstack" dependencies { - compile project(':testcontainers') + api project(':testcontainers') - compileOnly 'com.amazonaws:aws-java-sdk-s3:1.11.930' - testCompile 'com.amazonaws:aws-java-sdk-s3:1.11.929' - testCompile 'com.amazonaws:aws-java-sdk-sqs:1.11.930' - testCompile 'com.amazonaws:aws-java-sdk-logs:1.11.807' - testCompile 'software.amazon.awssdk:s3:2.15.56' + 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 index b8416afb1ed..ce50313d429 100644 --- a/modules/localstack/src/main/java/org/testcontainers/containers/localstack/LocalStackContainer.java +++ b/modules/localstack/src/main/java/org/testcontainers/containers/localstack/LocalStackContainer.java @@ -1,9 +1,6 @@ package org.testcontainers.containers.localstack; -import com.amazonaws.auth.AWSCredentialsProvider; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.client.builder.AwsClientBuilder; +import com.github.dockerjava.api.command.InspectContainerResponse; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; @@ -12,6 +9,7 @@ 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; @@ -25,23 +23,41 @@ import java.util.stream.Collectors; /** - *

Container for Atlassian Labs Localstack, 'A fully functional local AWS cloud stack'.

- *

{@link LocalStackContainer#withServices(Service...)} should be used to select which services - * are to be launched. See {@link Service} for available choices. It is advised that - * {@link LocalStackContainer#getEndpointConfiguration(Service)} and - * {@link LocalStackContainer#getDefaultCredentialsProvider()} - * be used to obtain compatible endpoint configuration and credentials, respectively.

+ * 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 final List services = new ArrayList<>(); + + 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; @@ -60,7 +76,18 @@ public class LocalStackContainer extends GenericContainer { private final boolean legacyMode; /** - * @deprecated use {@link LocalStackContainer(DockerImageName)} instead + * 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() { @@ -68,7 +95,7 @@ public LocalStackContainer() { } /** - * @deprecated use {@link LocalStackContainer(DockerImageName)} instead + * @deprecated use {@link #LocalStackContainer(DockerImageName)} instead */ @Deprecated public LocalStackContainer(String version) { @@ -85,20 +112,57 @@ public LocalStackContainer(final DockerImageName dockerImageName) { /** * @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); + 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 shouldRunInLegacyMode(String version) { + 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; } @@ -109,7 +173,9 @@ private static boolean shouldRunInLegacyMode(String version) { } 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."); + log.warn( + "Consider using \"LocalStackContainer(DockerImageName dockerImageName, boolean legacyMode)\" constructor if you want to disable legacy mode." + ); return true; } @@ -117,31 +183,101 @@ private static boolean shouldRunInLegacyMode(String version) { protected void configure() { super.configure(); - Preconditions.check("services list must not be empty", !services.isEmpty()); + 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); + } - withEnv("SERVICES", services.stream().map(Service::getLocalStackName).collect(Collectors.joining(","))); + 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(HOSTNAME_EXTERNAL_ENV_VAR)) { + if (getEnvMap().containsKey(envVar)) { // do nothing hostnameExternalReason = "explicitly as environment variable"; } else if (getNetwork() != null && getNetworkAliases() != null && getNetworkAliases().size() >= 1) { - withEnv(HOSTNAME_EXTERNAL_ENV_VAR, getNetworkAliases().get(getNetworkAliases().size() - 1)); // use the last network alias set + 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(HOSTNAME_EXTERNAL_ENV_VAR, getHost()); + withEnv(envVar, getHost()); hostnameExternalReason = "to match host-routable address for container"; } - logger().info("{} environment variable set to {} ({})", HOSTNAME_EXTERNAL_ENV_VAR, getEnvMap().get(HOSTNAME_EXTERNAL_ENV_VAR), hostnameExternalReason); - exposePorts(); + logger() + .info("{} environment variable set to {} ({})", envVar, getEnvMap().get(envVar), hostnameExternalReason); } private void exposePorts() { - services.stream() - .map(this::getServicePort) - .distinct() - .forEach(this::addExposedPort); + 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(); } /** @@ -149,39 +285,13 @@ private void exposePorts() { * @param services one or more service names * @return this container object */ - public LocalStackContainer withServices(Service... services) { + public LocalStackContainer withServices(EnabledService... services) { this.services.addAll(Arrays.asList(services)); return self(); } - /** - * Provides an endpoint configuration that is preconfigured to communicate with a given simulated service. - * The provided endpoint configuration should be set in the AWS Java SDK when building a client, e.g.: - *

AmazonS3 s3 = AmazonS3ClientBuilder
-            .standard()
-            .withEndpointConfiguration(localstack.getEndpointConfiguration(S3))
-            .withCredentials(localstack.getDefaultCredentialsProvider())
-            .build();
-     
- * or for AWS SDK v2 - *
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 AwsClientBuilder.EndpointConfiguration} - */ - public AwsClientBuilder.EndpointConfiguration getEndpointConfiguration(Service service) { - return new AwsClientBuilder.EndpointConfiguration(getEndpointOverride(service).toString(), getRegion()); + public URI getEndpointOverride(Service service) { + return getEndpointOverride((EnabledService) service); } /** @@ -203,52 +313,54 @@ public AwsClientBuilder.EndpointConfiguration getEndpointConfiguration(Service s * @param service the service that is to be accessed * @return an {@link URI} endpoint override */ - public URI getEndpointOverride(Service service) { + 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))); + return new URI("http://" + ipAddress + ":" + getMappedPort(getServicePort(service))); } catch (UnknownHostException | URISyntaxException e) { throw new IllegalStateException("Cannot obtain endpoint URL", e); } } - private int getServicePort(Service service) { - return legacyMode ? service.port : PORT; - } - /** - * Provides a {@link AWSCredentialsProvider} that is preconfigured to communicate with a given simulated service. - * The credentials provider should be set in the AWS Java SDK when building a client, e.g.: - *
AmazonS3 s3 = AmazonS3ClientBuilder
-            .standard()
-            .withEndpointConfiguration(localstack.getEndpointConfiguration(S3))
-            .withCredentials(localstack.getDefaultCredentialsProvider())
-            .build();
-     
- * or for AWS SDK v2 you can use {@link #getAccessKey()}, {@link #getSecretKey()} directly: + * 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.getEndpointOverride(LocalStackContainer.Service.S3))
+             .endpointOverride(localstack.getEndpoint())
              .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(
              localstack.getAccessKey(), localstack.getSecretKey()
              )))
              .region(Region.of(localstack.getRegion()))
              .build()
-     
- * @return an {@link AWSCredentialsProvider} + + *

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 AWSCredentialsProvider getDefaultCredentialsProvider() { - return new AWSStaticCredentialsProvider(new BasicAWSCredentials(getAccessKey(), getSecretKey())); + 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()
@@ -262,11 +374,12 @@ public AWSCredentialsProvider getDefaultCredentialsProvider() {
      * @return a default access key
      */
     public String getAccessKey() {
-        return "accesskey";
+        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()
@@ -280,7 +393,7 @@ public String getAccessKey() {
      * @return a default secret key
      */
     public String getSecretKey() {
-        return "secretkey";
+        return this.getEnvMap().getOrDefault("AWS_SECRET_ACCESS_KEY", DEFAULT_AWS_SECRET_ACCESS_KEY);
     }
 
     /**
@@ -298,19 +411,32 @@ public String getSecretKey() {
      * @return a default region
      */
     public String getRegion() {
-        return "us-east-1";
+        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 {
+    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),
+        //        ELASTICSEARCH("es",           4571),
         S3("s3", 4572),
         FIREHOSE("firehose", 4573),
         LAMBDA("lambda", 4574),
@@ -334,6 +460,11 @@ public enum Service {
 
         int port;
 
+        @Override
+        public String getName() {
+            return localStackName;
+        }
+
         @Deprecated
         /*
             Since version 0.11, LocalStack exposes all services on a single (4566) 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 index f50846b091f..b2537d70b77 100644 --- a/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LegacyModeTest.java +++ b/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LegacyModeTest.java @@ -1,128 +1,118 @@ package org.testcontainers.containers.localstack; -import lombok.AllArgsConstructor; -import lombok.SneakyThrows; -import org.junit.After; -import org.junit.BeforeClass; -import org.junit.Test; -import org.junit.experimental.runners.Enclosed; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.util.Arrays; -import java.util.function.Consumer; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertNotEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3; -import static org.testcontainers.containers.localstack.LocalStackContainer.Service.SQS; -import static org.testcontainers.containers.localstack.LocalstackTestImages.LOCALSTACK_IMAGE; - -@RunWith(Enclosed.class) -public class LegacyModeTest { - - @RunWith(Parameterized.class) - @AllArgsConstructor - public static class Off { - private final String description; - private final LocalStackContainer localstack; - - @Parameterized.Parameters(name = "{0}") - public static Iterable constructors() { - return Arrays.asList(new Object[][]{ - {"default constructor", new LocalStackContainer(LOCALSTACK_IMAGE)}, - {"latest", new LocalStackContainer(LOCALSTACK_IMAGE.withTag("latest"))}, - {"0.11.1", new LocalStackContainer(LOCALSTACK_IMAGE.withTag("0.11.1"))}, - {"0.7.0 with legacy = off", new LocalStackContainer(LOCALSTACK_IMAGE.withTag("0.7.0"), false)} - }); - } - - @Test - public void samePortIsExposedForAllServices() { - localstack.withServices(S3, SQS); - localstack.start(); - - assertTrue("A single port is exposed", localstack.getExposedPorts().size() == 1); - assertEquals( - "Endpoint overrides are different", - localstack.getEndpointOverride(S3).toString(), - localstack.getEndpointOverride(SQS).toString()); - assertEquals( - "Endpoint configuration have different endpoints", - localstack.getEndpointConfiguration(S3).getServiceEndpoint(), - localstack.getEndpointConfiguration(SQS).getServiceEndpoint()); - } - - @After - public void cleanup() { - if (localstack != null) localstack.stop(); - } +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(); } - @RunWith(Parameterized.class) - @AllArgsConstructor - public static class On { - private final String description; - private final LocalStackContainer localstack; - - @BeforeClass - public static void createCustomTag() { - run("docker pull localstack/localstack:latest"); - run("docker tag localstack/localstack:latest localstack/localstack:custom"); - } + 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) + ) + ); + } - @Parameterized.Parameters(name = "{0}") - public static Iterable constructors() { - return Arrays.asList(new Object[][]{ - {"0.10.7", new LocalStackContainer(LOCALSTACK_IMAGE.withTag("0.10.7"))}, - {"custom", new LocalStackContainer(LOCALSTACK_IMAGE.withTag("custom"))}, - {"0.11.1 with legacy = on", new LocalStackContainer(LOCALSTACK_IMAGE.withTag("0.11.1"), true)} - }); + @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(); } + } - @Test - public void differentPortsAreExposed() { - localstack.withServices(S3, SQS); - localstack.start(); - - assertTrue("Multiple ports are exposed", localstack.getExposedPorts().size() > 1); - assertNotEquals( - "Endpoint overrides are different", - localstack.getEndpointOverride(S3).toString(), - localstack.getEndpointOverride(SQS).toString()); - assertNotEquals( - "Endpoint configuration have different endpoints", - localstack.getEndpointConfiguration(S3).getServiceEndpoint(), - localstack.getEndpointConfiguration(SQS).getServiceEndpoint()); - } + 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) + ) + ); + } - @After - public void cleanup() { - if (localstack != null) localstack.stop(); + @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(); } } - @SneakyThrows - private static void run(String command) { - Process process = Runtime.getRuntime().exec(command); - join(process.getInputStream(), System.out::println); - join(process.getErrorStream(), System.err::println); - process.waitFor(); - if (process.exitValue() != 0) - throw new RuntimeException("Failed to execute " + command); + 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) + ); } - private static void join(InputStream stream, Consumer logger) throws IOException { - BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(stream)); - String line; - while ((line = bufferedReader.readLine()) != null) { - logger.accept(line); - } + @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/LocalstackContainerTest.java b/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackContainerTest.java deleted file mode 100644 index af4d0694ab6..00000000000 --- a/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackContainerTest.java +++ /dev/null @@ -1,233 +0,0 @@ -package org.testcontainers.containers.localstack; - - -import static org.hamcrest.CoreMatchers.containsString; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThat; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.testcontainers.containers.localstack.LocalStackContainer.PORT; -import static org.testcontainers.containers.localstack.LocalStackContainer.Service.CLOUDWATCHLOGS; -import static org.testcontainers.containers.localstack.LocalStackContainer.Service.KMS; -import static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3; -import static org.testcontainers.containers.localstack.LocalStackContainer.Service.SQS; -import static org.testcontainers.containers.localstack.LocalstackTestImages.LOCALSTACK_IMAGE; - -import com.amazonaws.services.kms.AWSKMS; -import com.amazonaws.services.kms.AWSKMSClientBuilder; -import com.amazonaws.services.kms.model.CreateKeyRequest; -import com.amazonaws.services.kms.model.CreateKeyResult; -import com.amazonaws.services.kms.model.Tag; -import com.amazonaws.services.logs.AWSLogs; -import com.amazonaws.services.logs.AWSLogsClientBuilder; -import com.amazonaws.services.logs.model.CreateLogGroupRequest; -import com.amazonaws.services.logs.model.LogGroup; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; -import com.amazonaws.services.s3.model.Bucket; -import com.amazonaws.services.s3.model.ObjectListing; -import com.amazonaws.services.s3.model.S3Object; -import com.amazonaws.services.sqs.AmazonSQS; -import com.amazonaws.services.sqs.AmazonSQSClientBuilder; -import com.amazonaws.services.sqs.model.CreateQueueResult; -import java.io.IOException; -import java.nio.charset.Charset; -import java.util.List; -import java.util.Optional; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.io.IOUtils; -import org.junit.Assert; -import org.junit.ClassRule; -import org.junit.Test; -import org.junit.experimental.runners.Enclosed; -import org.junit.runner.RunWith; -import org.testcontainers.DockerClientFactory; -import org.testcontainers.containers.Container; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.Network; -import org.testcontainers.utility.DockerImageName; -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.s3.S3Client; - -/** - * Tests for Localstack Container, used both in bridge network (exposed to host) and docker network modes. - *

- * These tests attempt simple interactions with the container to verify behaviour. The bridge network tests use the - * Java AWS SDK, whereas the docker network tests use an AWS CLI container within the network, to simulate usage of - * Localstack from within a Docker network. - */ -@Slf4j -@RunWith(Enclosed.class) -public class LocalstackContainerTest { - - public static class WithoutNetwork { - - // without_network { - @ClassRule - public static LocalStackContainer localstack = new LocalStackContainer(LOCALSTACK_IMAGE) - .withServices(S3, SQS, CLOUDWATCHLOGS, KMS); - // } - - @Test - public void s3TestOverBridgeNetwork() throws IOException { - AmazonS3 s3 = AmazonS3ClientBuilder - .standard() - .withEndpointConfiguration(localstack.getEndpointConfiguration(S3)) - .withCredentials(localstack.getDefaultCredentialsProvider()) - .build(); - - final String bucketName = "foo"; - s3.createBucket(bucketName); - s3.putObject(bucketName, "bar", "baz"); - - final List buckets = s3.listBuckets(); - final Optional maybeBucket = buckets.stream().filter(b -> b.getName().equals(bucketName)).findFirst(); - assertTrue("The created bucket is present", maybeBucket.isPresent()); - final Bucket bucket = maybeBucket.get(); - - assertEquals("The created bucket has the right name", bucketName, bucket.getName()); - - final ObjectListing objectListing = s3.listObjects(bucketName); - assertEquals("The created bucket has 1 item in it", 1, objectListing.getObjectSummaries().size()); - - final S3Object object = s3.getObject(bucketName, "bar"); - final String content = IOUtils.toString(object.getObjectContent(), Charset.forName("UTF-8")); - assertEquals("The object can be retrieved", "baz", content); - } - - @Test - public void s3TestUsingAwsSdkV2() { - 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(); - - final String bucketName = "foov2"; - s3.createBucket(b -> b.bucket(bucketName)); - assertTrue("New bucket was created", s3.listBuckets().buckets().stream().anyMatch(b -> b.name().equals(bucketName))); - } - - @Test - public void sqsTestOverBridgeNetwork() { - AmazonSQS sqs = AmazonSQSClientBuilder.standard() - .withEndpointConfiguration(localstack.getEndpointConfiguration(SQS)) - .withCredentials(localstack.getDefaultCredentialsProvider()) - .build(); - - CreateQueueResult queueResult = sqs.createQueue("baz"); - String fooQueueUrl = queueResult.getQueueUrl(); - assertThat("Created queue has external hostname URL", fooQueueUrl, - containsString("http://" + DockerClientFactory.instance().dockerHostIpAddress() + ":" + localstack.getMappedPort(PORT))); - - sqs.sendMessage(fooQueueUrl, "test"); - final long messageCount = sqs.receiveMessage(fooQueueUrl).getMessages().stream() - .filter(message -> message.getBody().equals("test")) - .count(); - assertEquals("the sent message can be received", 1L, messageCount); - } - - @Test - public void cloudWatchLogsTestOverBridgeNetwork() { - AWSLogs logs = AWSLogsClientBuilder.standard() - .withEndpointConfiguration(localstack.getEndpointConfiguration(CLOUDWATCHLOGS)) - .withCredentials(localstack.getDefaultCredentialsProvider()).build(); - - logs.createLogGroup(new CreateLogGroupRequest("foo")); - - List groups = logs.describeLogGroups().getLogGroups(); - assertEquals("One log group should be created", 1, groups.size()); - assertEquals("Name of created log group is [foo]", "foo", groups.get(0).getLogGroupName()); - } - - @Test - public void kmsKeyCreationTest() { - AWSKMS awskms = AWSKMSClientBuilder.standard() - .withEndpointConfiguration(localstack.getEndpointConfiguration(KMS)) - .withCredentials(localstack.getDefaultCredentialsProvider()) - .build(); - - String desc = String.format("AWS CMK Description"); - Tag createdByTag = new Tag().withTagKey("CreatedBy").withTagValue("StorageService"); - CreateKeyRequest req = new CreateKeyRequest().withDescription(desc).withTags(createdByTag); - CreateKeyResult key = awskms.createKey(req); - - assertEquals("AWS KMS Customer Managed Key should be created ", key.getKeyMetadata().getDescription(), desc); - } - - @Test - public void samePortIsExposedForAllServices() { - assertTrue("A single port is exposed", localstack.getExposedPorts().size() == 1); - assertEquals( - "Endpoint overrides are different", - localstack.getEndpointOverride(S3).toString(), - localstack.getEndpointOverride(SQS).toString()); - assertEquals( - "Endpoint configuration have different endpoints", - localstack.getEndpointConfiguration(S3).getServiceEndpoint(), - localstack.getEndpointConfiguration(SQS).getServiceEndpoint()); - } - } - - public static class WithNetwork { - // with_network { - private static Network network = Network.newNetwork(); - - @ClassRule - public static LocalStackContainer localstackInDockerNetwork = new LocalStackContainer(LOCALSTACK_IMAGE) - .withNetwork(network) - .withNetworkAliases("notthis", "localstack") // the last alias is used for HOSTNAME_EXTERNAL - .withServices(S3, SQS, CLOUDWATCHLOGS); - // } - - @ClassRule - public static GenericContainer awsCliInDockerNetwork = new GenericContainer<>(LocalstackTestImages.AWS_CLI_IMAGE) - .withNetwork(network) - .withCreateContainerCmdModifier(cmd -> cmd.withEntrypoint("top")) - .withEnv("AWS_ACCESS_KEY_ID", "accesskey") - .withEnv("AWS_SECRET_ACCESS_KEY", "secretkey") - .withEnv("AWS_REGION", "eu-west-1"); - - - @Test - public void s3TestOverDockerNetwork() throws Exception { - runAwsCliAgainstDockerNetworkContainer("s3api create-bucket --bucket foo"); - runAwsCliAgainstDockerNetworkContainer("s3api list-buckets"); - runAwsCliAgainstDockerNetworkContainer("s3 ls s3://foo"); - } - - @Test - public void sqsTestOverDockerNetwork() throws Exception { - final String queueCreationResponse = runAwsCliAgainstDockerNetworkContainer("sqs create-queue --queue-name baz"); - - assertThat("Created queue has external hostname URL", queueCreationResponse, - containsString("http://localstack:" + PORT)); - - runAwsCliAgainstDockerNetworkContainer( - String.format("sqs send-message --endpoint http://localstack:%d --queue-url http://localstack:%d/queue/baz --message-body test", PORT, PORT)); - final String message = runAwsCliAgainstDockerNetworkContainer( - String.format("sqs receive-message --endpoint http://localstack:%d --queue-url http://localstack:%d/queue/baz", PORT, PORT)); - - assertTrue("the sent message can be received", message.contains("\"Body\": \"test\"")); - } - - @Test - public 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/bin/aws --region eu-west-1 %s --endpoint-url http://localstack:%d --no-verify-ssl", command, PORT).split(" "); - final Container.ExecResult execResult = awsCliInDockerNetwork.execInContainer(commandParts); - Assert.assertEquals(0, execResult.getExitCode()); - - final String logs = execResult.getStdout() + execResult.getStderr(); - log.info(logs); - return logs; - } - } -} 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 index 0dd0ca9b9ac..9ca7dd6614f 100644 --- a/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackTestImages.java +++ b/modules/localstack/src/test/java/org/testcontainers/containers/localstack/LocalstackTestImages.java @@ -3,6 +3,13 @@ import org.testcontainers.utility.DockerImageName; public interface LocalstackTestImages { - DockerImageName LOCALSTACK_IMAGE = DockerImageName.parse("localstack/localstack:0.11.3"); - DockerImageName AWS_CLI_IMAGE = DockerImageName.parse("atlassian/pipelines-awscli:1.16.302"); + 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 index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/localstack/src/test/resources/logback-test.xml +++ b/modules/localstack/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + diff --git a/modules/mariadb/build.gradle b/modules/mariadb/build.gradle index 915e14eb7ee..10086b4e058 100644 --- a/modules/mariadb/build.gradle +++ b/modules/mariadb/build.gradle @@ -1,17 +1,14 @@ description = "Testcontainers :: JDBC :: MariaDB" dependencies { - annotationProcessor 'com.google.auto.service:auto-service:1.0-rc7' - compileOnly 'com.google.auto.service:auto-service:1.0-rc6' + api project(':testcontainers-jdbc') - compile project(':jdbc') + compileOnly project(':testcontainers-r2dbc') + compileOnly 'org.mariadb:r2dbc-mariadb:1.0.3' - compileOnly project(':r2dbc') - compileOnly 'org.mariadb:r2dbc-mariadb:1.0.0' + testImplementation project(':testcontainers-jdbc-test') + testImplementation 'org.mariadb.jdbc:mariadb-java-client:3.5.9' - testCompile project(':jdbc-test') - testCompile 'org.mariadb.jdbc:mariadb-java-client:2.7.0' - - testCompile testFixtures(project(':r2dbc')) - testCompile 'org.mariadb:r2dbc-mariadb:1.0.0' + testImplementation testFixtures(project(':testcontainers-r2dbc')) + testRuntimeOnly 'org.mariadb:r2dbc-mariadb:1.0.3' } 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 b43a5f237e5..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,12 +1,21 @@ 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. + * Testcontainers implementation for MariaDB. + *

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

+ * Exposed ports: 3306 * - * @author Miguel Gonzalez Sanchez + * @deprecated use {@link org.testcontainers.mariadb.MariaDBContainer} instead. */ +@Deprecated public class MariaDBContainer> extends JdbcDatabaseContainer { private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("mariadb"); @@ -24,19 +33,16 @@ public class MariaDBContainer> extends JdbcD 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"; - /** - * @deprecated use {@link MariaDBContainer(DockerImageName)} instead - */ - @Deprecated - public MariaDBContainer() { - this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); - } + private static final String MY_CNF_CONFIG_OVERRIDE_PARAM_NAME = "TC_MY_CNF"; public MariaDBContainer(String dockerImageName) { this(DockerImageName.parse(dockerImageName)); @@ -44,23 +50,30 @@ 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"); + optionallyMapResourceParameterAsVolume( + MY_CNF_CONFIG_OVERRIDE_PARAM_NAME, + "/etc/mysql/conf.d", + "mariadb-default-conf", + Transferable.DEFAULT_DIR_MODE + ); addEnv("MYSQL_DATABASE", databaseName); - addEnv("MYSQL_USER", username); + + 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); @@ -80,13 +93,14 @@ public String getDriverClassName() { @Override public String getJdbcUrl() { String additionalUrlParams = constructUrlParameters("?", "&"); - return "jdbc:mariadb://" + getHost() + ":" + getMappedPort(MARIADB_PORT) + - "/" + databaseName + additionalUrlParams; + return ( + "jdbc:mariadb://" + getHost() + ":" + getMappedPort(MARIADB_PORT) + "/" + databaseName + additionalUrlParams + ); } @Override public String getDatabaseName() { - return databaseName; + return databaseName; } @Override 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 4330465a9c3..6697255ca79 100644 --- a/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBContainerProvider.java +++ b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBContainerProvider.java @@ -29,7 +29,6 @@ public JdbcDatabaseContainer newInstance(String tag) { @Override public JdbcDatabaseContainer newInstance(ConnectionUrl connectionUrl) { - return newInstanceFromConnectionUrl(connectionUrl, USER_PARAM, PASSWORD_PARAM); + 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 index e37e9b0b6bf..99041e83e28 100644 --- a/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainer.java +++ b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainer.java @@ -13,7 +13,8 @@ public class MariaDBR2DBCDatabaseContainer implements R2DBCDatabaseContainer { private final MariaDBContainer container; public static ConnectionFactoryOptions getOptions(MariaDBContainer container) { - ConnectionFactoryOptions options = ConnectionFactoryOptions.builder() + ConnectionFactoryOptions options = ConnectionFactoryOptions + .builder() .option(ConnectionFactoryOptions.DRIVER, MariaDBR2DBCDatabaseContainerProvider.DRIVER) .build(); @@ -22,7 +23,8 @@ public static ConnectionFactoryOptions getOptions(MariaDBContainer container) @Override public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { - return options.mutate() + return options + .mutate() .option(ConnectionFactoryOptions.HOST, container.getHost()) .option(ConnectionFactoryOptions.PORT, container.getMappedPort(MariaDBContainer.MARIADB_PORT)) .option(ConnectionFactoryOptions.DATABASE, container.getDatabaseName()) diff --git a/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerProvider.java b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerProvider.java index 36a201f85bb..34635ac239f 100644 --- a/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerProvider.java +++ b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerProvider.java @@ -1,6 +1,5 @@ package org.testcontainers.containers; -import com.google.auto.service.AutoService; import io.r2dbc.spi.ConnectionFactoryMetadata; import io.r2dbc.spi.ConnectionFactoryOptions; import org.mariadb.r2dbc.MariadbConnectionFactoryProvider; @@ -9,7 +8,6 @@ import javax.annotation.Nullable; -@AutoService(R2DBCDatabaseContainerProvider.class) public class MariaDBR2DBCDatabaseContainerProvider implements R2DBCDatabaseContainerProvider { static final String DRIVER = MariadbConnectionFactoryProvider.MARIADB_DRIVER; @@ -23,7 +21,7 @@ public boolean supports(ConnectionFactoryOptions options) { public R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options) { String image = MariaDBContainer.IMAGE + ":" + options.getRequiredValue(IMAGE_TAG_OPTION); MariaDBContainer container = new MariaDBContainer<>(image) - .withDatabaseName(options.getRequiredValue(ConnectionFactoryOptions.DATABASE)); + .withDatabaseName((String) options.getRequiredValue(ConnectionFactoryOptions.DATABASE)); if (Boolean.TRUE.equals(options.getValue(REUSABLE_OPTION))) { container.withReuse(true); 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/test/java/org/testcontainers/MariaDBTestImages.java b/modules/mariadb/src/test/java/org/testcontainers/MariaDBTestImages.java index 7d18f2f8365..de5360cb57c 100644 --- a/modules/mariadb/src/test/java/org/testcontainers/MariaDBTestImages.java +++ b/modules/mariadb/src/test/java/org/testcontainers/MariaDBTestImages.java @@ -3,5 +3,5 @@ import org.testcontainers.utility.DockerImageName; public interface MariaDBTestImages { - DockerImageName MARIADB_IMAGE = DockerImageName.parse("mariadb:10.3.6"); + 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 index b6fb4dd659e..0f4acd889d5 100644 --- a/modules/mariadb/src/test/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerTest.java +++ b/modules/mariadb/src/test/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerTest.java @@ -13,12 +13,11 @@ protected ConnectionFactoryOptions getOptions(MariaDBContainer container) { @Override protected String createR2DBCUrl() { - return "r2dbc:tc:mariadb:///db?TC_IMAGE_TAG=10.3.6"; + return "r2dbc:tc:mariadb:///db?TC_IMAGE_TAG=10.3.39"; } @Override protected MariaDBContainer createContainer() { - return new MariaDBContainer<>(DockerImageName.parse("mariadb:10.3.6")); + 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 index ef1dfef2703..5d3b799f0ff 100644 --- a/modules/mariadb/src/test/java/org/testcontainers/jdbc/mariadb/MariaDBJDBCDriverTest.java +++ b/modules/mariadb/src/test/java/org/testcontainers/jdbc/mariadb/MariaDBJDBCDriverTest.java @@ -1,29 +1,46 @@ package org.testcontainers.jdbc.mariadb; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; import org.testcontainers.jdbc.AbstractJDBCDriverTest; +import java.util.Arrays; import java.util.EnumSet; -import static java.util.Arrays.asList; +class MariaDBJDBCDriverTest extends AbstractJDBCDriverTest { -@RunWith(Parameterized.class) -public class MariaDBJDBCDriverTest extends AbstractJDBCDriverTest { - - @Parameterized.Parameters(name = "{index} - {0}") public static Iterable data() { - return 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.2.14://hostname/databasename", EnumSet.noneOf(Options.class)}, - {"jdbc:tc:mariadb:10.2.14://hostname/databasename?TC_INITSCRIPT=somepath/init_unicode_mariadb.sql&useUnicode=yes&characterEncoding=utf8", EnumSet.of(Options.CharacterSet)}, - {"jdbc:tc:mariadb:10.2.14://hostname/databasename?user=someuser&TC_INITSCRIPT=somepath/init_mariadb.sql", EnumSet.of(Options.ScriptedSchema, Options.JDBCParams)}, - {"jdbc:tc:mariadb:10.2.14://hostname/databasename?user=someuser&TC_INITFUNCTION=org.testcontainers.jdbc.AbstractJDBCDriverTest::sampleInitFunction", EnumSet.of(Options.ScriptedSchema, Options.JDBCParams)}, - {"jdbc:tc:mariadb:10.2.14://hostname/databasename?user=someuser&password=somepwd&TC_INITSCRIPT=somepath/init_mariadb.sql", EnumSet.of(Options.ScriptedSchema, Options.JDBCParams)}, - {"jdbc:tc:mariadb:10.2.14://hostname/databasename?user=someuser&password=somepwd&TC_INITFUNCTION=org.testcontainers.jdbc.AbstractJDBCDriverTest::sampleInitFunction", EnumSet.of(Options.ScriptedSchema, Options.JDBCParams)}, - {"jdbc:tc:mariadb:10.2.14://hostname/databasename?TC_MY_CNF=somepath/mariadb_conf_override", EnumSet.of(Options.CustomIniFile)}, - }); + 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/junit/mariadb/SimpleMariaDBTest.java b/modules/mariadb/src/test/java/org/testcontainers/junit/mariadb/SimpleMariaDBTest.java deleted file mode 100644 index 4ea98bbe1fe..00000000000 --- a/modules/mariadb/src/test/java/org/testcontainers/junit/mariadb/SimpleMariaDBTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package org.testcontainers.junit.mariadb; - -import org.apache.commons.lang.SystemUtils; -import org.junit.Test; -import org.testcontainers.containers.MariaDBContainer; -import org.testcontainers.db.AbstractContainerDatabaseTest; - -import java.sql.ResultSet; -import java.sql.SQLException; - -import static org.hamcrest.CoreMatchers.containsString; -import static org.junit.Assert.assertThat; -import static org.junit.Assume.assumeFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.testcontainers.MariaDBTestImages.MARIADB_IMAGE; - - -public class SimpleMariaDBTest extends AbstractContainerDatabaseTest { - - @Test - public void testSimple() throws SQLException { - try (MariaDBContainer mariadb = new MariaDBContainer<>(MARIADB_IMAGE)) { - - mariadb.start(); - - ResultSet resultSet = performQuery(mariadb, "SELECT 1"); - int resultSetInt = resultSet.getInt(1); - - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } - } - - @Test - public void testSpecificVersion() throws SQLException { - try (MariaDBContainer mariadbOldVersion = new MariaDBContainer<>(MARIADB_IMAGE.withTag("5.5.51"))) { - - mariadbOldVersion.start(); - - 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")); - } - } - - @Test - public void testMariaDBWithCustomIniFile() throws SQLException { - assumeFalse(SystemUtils.IS_OS_WINDOWS); - - try (MariaDBContainer mariadbCustomConfig = new MariaDBContainer<>(MARIADB_IMAGE.withTag("10.1.16")) - .withConfigurationOverride("somepath/mariadb_conf_override")) { - mariadbCustomConfig.start(); - - 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); - } - } - - @Test - public void testMariaDBWithCommandOverride() throws SQLException { - - try (MariaDBContainer mariadbCustomConfig = new MariaDBContainer<>(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"); - - assertEquals("Auto increment increment should be overriden by command line", "10", result); - } - } - - @Test - public void testWithAdditionalUrlParamInJdbcUrl() { - MariaDBContainer mariaDBContainer = new MariaDBContainer<>(MARIADB_IMAGE) - .withUrlParam("connectTimeout", "40000") - .withUrlParam("rewriteBatchedStatements", "true"); - - try { - mariaDBContainer.start(); - String jdbcUrl = mariaDBContainer.getJdbcUrl(); - assertThat(jdbcUrl, containsString("?")); - assertThat(jdbcUrl, containsString("&")); - assertThat(jdbcUrl, containsString("rewriteBatchedStatements=true")); - assertThat(jdbcUrl, containsString("connectTimeout=40000")); - } finally { - mariaDBContainer.stop(); - } - } -} 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 index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/mariadb/src/test/resources/logback-test.xml +++ b/modules/mariadb/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + diff --git a/modules/mariadb/src/test/resources/somepath/mariadb_conf_override/my.cnf b/modules/mariadb/src/test/resources/somepath/mariadb_conf_override/my.cnf index dcba17aff5c..c997afbfebc 100644 --- a/modules/mariadb/src/test/resources/somepath/mariadb_conf_override/my.cnf +++ b/modules/mariadb/src/test/resources/somepath/mariadb_conf_override/my.cnf @@ -9,16 +9,12 @@ 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 - -innodb_file_format=Barracuda - - - - +# 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. @@ -52,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 index b463997fed5..2ff2e9f34c6 100644 --- a/modules/mockserver/build.gradle +++ b/modules/mockserver/build.gradle @@ -1,7 +1,8 @@ description = "Testcontainers :: MockServer" dependencies { - compile project(':testcontainers') + api project(':testcontainers') - testCompile 'org.mock-server:mockserver-client-java:5.5.4' + 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 index 0502469208e..47d2399d934 100644 --- a/modules/mockserver/src/main/java/org/testcontainers/containers/MockServerContainer.java +++ b/modules/mockserver/src/main/java/org/testcontainers/containers/MockServerContainer.java @@ -4,10 +4,15 @@ 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 @@ -16,7 +21,7 @@ public class MockServerContainer extends GenericContainer { public static final int PORT = 1080; /** - * @deprecated use {@link MockServerContainer(DockerImageName)} instead + * @deprecated use {@link #MockServerContainer(DockerImageName)} instead */ @Deprecated public MockServerContainer() { @@ -24,7 +29,7 @@ public MockServerContainer() { } /** - * @deprecated use {@link MockServerContainer(DockerImageName)} instead + * @deprecated use {@link #MockServerContainer(DockerImageName)} instead */ @Deprecated public MockServerContainer(String version) { @@ -33,12 +38,11 @@ public MockServerContainer(String version) { public MockServerContainer(DockerImageName dockerImageName) { super(dockerImageName); - dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, DockerImageName.parse("mockserver/mockserver")); - waitingFor(Wait.forHttp("/mockserver/status").withMethod("PUT").forStatusCode(200)); + waitingFor(Wait.forLogMessage(".*started on port: " + PORT + ".*", 1)); - withCommand("-logLevel INFO -serverPort " + PORT); + withCommand("-serverPort " + PORT); addExposedPorts(PORT); } @@ -46,6 +50,10 @@ 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/containers/MockServerContainerRuleTest.java b/modules/mockserver/src/test/java/org/testcontainers/containers/MockServerContainerRuleTest.java deleted file mode 100644 index d0c290ad46a..00000000000 --- a/modules/mockserver/src/test/java/org/testcontainers/containers/MockServerContainerRuleTest.java +++ /dev/null @@ -1,40 +0,0 @@ -package org.testcontainers.containers; - -import org.junit.Rule; -import org.junit.Test; -import org.mockserver.client.MockServerClient; -import org.testcontainers.utility.DockerImageName; - -import static org.hamcrest.CoreMatchers.containsString; -import static org.mockserver.model.HttpRequest.request; -import static org.mockserver.model.HttpResponse.response; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThat; - -public class MockServerContainerRuleTest { - - public static final DockerImageName MOCKSERVER_IMAGE = DockerImageName.parse("jamesdbloom/mockserver:mockserver-5.5.4"); - - // creatingProxy { - @Rule - public MockServerContainer mockServer = new MockServerContainer(MOCKSERVER_IMAGE); - // } - - @Test - public void shouldReturnExpectation() throws Exception { - // testSimpleExpectation { - new MockServerClient(mockServer.getHost(), mockServer.getServerPort()) - .when(request() - .withPath("/person") - .withQueryStringParameter("name", "peter")) - .respond(response() - .withBody("Peter the person!")); - - // ...a GET request to '/person?name=peter' returns "Peter the person!" - // } - - assertThat("Expectation returns expected response body", - SimpleHttpClient.responseFromMockserver(mockServer, "/person?name=peter"), - containsString("Peter the person") - ); - } -} diff --git a/modules/mockserver/src/test/java/org/testcontainers/containers/MockServerContainerTest.java b/modules/mockserver/src/test/java/org/testcontainers/containers/MockServerContainerTest.java deleted file mode 100644 index 61a5c33c630..00000000000 --- a/modules/mockserver/src/test/java/org/testcontainers/containers/MockServerContainerTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.testcontainers.containers; - -import org.junit.Test; -import org.mockserver.client.MockServerClient; -import org.testcontainers.utility.DockerImageName; - -import static org.hamcrest.CoreMatchers.equalTo; -import static org.mockserver.model.HttpRequest.request; -import static org.mockserver.model.HttpResponse.response; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThat; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; - -public class MockServerContainerTest { - - public static final DockerImageName MOCKSERVER_IMAGE = DockerImageName.parse("jamesdbloom/mockserver:mockserver-5.5.4"); - - @Test - public void shouldCallActualMockserverVersion() throws Exception { - String actualVersion = MockServerClient.class.getPackage().getImplementationVersion(); - try (MockServerContainer mockServer = new MockServerContainer(MOCKSERVER_IMAGE.withTag("mockserver-" + actualVersion))) { - mockServer.start(); - - String expectedBody = "Hello World!"; - - MockServerClient client = new MockServerClient(mockServer.getHost(), mockServer.getServerPort()); - - assertTrue("Mockserver running", client.isRunning()); - - client - .when(request().withPath("/hello")) - .respond(response().withBody(expectedBody)); - - assertThat("MockServer returns correct result", - SimpleHttpClient.responseFromMockserver(mockServer, "/hello"), - equalTo(expectedBody) - ); - } - } - - @Test - public void newVersionStartsWithDefaultWaitStrategy() { - DockerImageName dockerImageName = DockerImageName.parse("mockserver/mockserver:mockserver-5.11.2"); - try (MockServerContainer mockServer = new MockServerContainer(dockerImageName)) { - mockServer.start(); - } - } -} diff --git a/modules/mockserver/src/test/java/org/testcontainers/containers/SimpleHttpClient.java b/modules/mockserver/src/test/java/org/testcontainers/containers/SimpleHttpClient.java deleted file mode 100644 index cd5cb25cabb..00000000000 --- a/modules/mockserver/src/test/java/org/testcontainers/containers/SimpleHttpClient.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.testcontainers.containers; - -import lombok.Cleanup; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.URL; -import java.net.URLConnection; - -public class SimpleHttpClient { - public static String responseFromMockserver(MockServerContainer mockServer, String path) throws IOException { - URLConnection urlConnection = new URL(mockServer.getEndpoint() + path).openConnection(); - @Cleanup BufferedReader reader = new BufferedReader(new InputStreamReader(urlConnection.getInputStream())); - return reader.readLine(); - } -} 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 index 57db1be21de..8e9f1420ddf 100644 --- a/modules/mongodb/build.gradle +++ b/modules/mongodb/build.gradle @@ -1,7 +1,7 @@ description = "Testcontainers :: MongoDB" dependencies { - compile project(':testcontainers') + api project(':testcontainers') - testCompile("org.mongodb:mongodb-driver-sync:4.0.2") + 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 index 662c4b575f7..d30f45739c0 100644 --- a/modules/mongodb/src/main/java/org/testcontainers/containers/MongoDBContainer.java +++ b/modules/mongodb/src/main/java/org/testcontainers/containers/MongoDBContainer.java @@ -6,26 +6,47 @@ 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; /** - * Constructs a single node MongoDB replica set for testing transactions. - *

To construct a multi-node MongoDB cluster, consider the mongodb-replica-set project on GitHub - *

Tested on a MongoDB version 4.0.10+ (that is the default version if not specified). + * 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 MONGODB_INTERNAL_PORT = 27017; + 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 use {@link #MongoDBContainer(DockerImageName)} instead */ @Deprecated public MongoDBContainer() { @@ -38,14 +59,51 @@ public MongoDBContainer(@NonNull final String dockerImageName) { public MongoDBContainer(final DockerImageName dockerImageName) { super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, COMMUNITY_SERVER_IMAGE, ENTERPRISE_SERVER_IMAGE); + } - dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + @Override + MongoDBContainerDef createContainerDef() { + return new MongoDBContainerDef(); + } - withExposedPorts(MONGODB_INTERNAL_PORT); - withCommand("--replSet", "docker-rs"); - waitingFor( - Wait.forLogMessage("(?i).*waiting for connections.*", 1) - ); + @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)); } /** @@ -67,21 +125,15 @@ public String getReplicaSetUrl(final String databaseName) { if (!isRunning()) { throw new IllegalStateException("MongoDBContainer should be started first"); } - return String.format( - "mongodb://%s:%d/%s", - getContainerIpAddress(), - getMappedPort(MONGODB_INTERNAL_PORT), - databaseName - ); - } - - @Override - protected void containerIsStarted(InspectContainerResponse containerInfo) { - initReplicaSet(); + return getConnectionString() + "/" + databaseName; } private String[] buildMongoEvalCommand(final String command) { - return new String[]{"mongo", "--eval", command}; + return new String[] { + "sh", + "-c", + "mongosh mongo --eval \"" + command + "\" || mongo --eval \"" + command + "\"", + }; } private void checkMongoNodeExitCode(final Container.ExecResult execResult) { @@ -95,21 +147,19 @@ private void checkMongoNodeExitCode(final Container.ExecResult execResult) { private String buildMongoWaitCommand() { return String.format( "var attempt = 0; " + - "while" + - "(%s) " + - "{ " + - "if (attempt > %d) {quit(1);} " + - "print('%s ' + attempt); sleep(100); attempt++; " + - " }", + "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 - ) { + 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", @@ -120,30 +170,57 @@ private void checkMongoNodeExitCodeAfterWaiting( } } - @SneakyThrows(value = {IOException.class, InterruptedException.class}) - private void initReplicaSet() { - 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()); + @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); + 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/containers/MongoDBContainerTest.java b/modules/mongodb/src/test/java/org/testcontainers/containers/MongoDBContainerTest.java deleted file mode 100644 index ff5243acea7..00000000000 --- a/modules/mongodb/src/test/java/org/testcontainers/containers/MongoDBContainerTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package org.testcontainers.containers; - -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 org.junit.Test; -import org.testcontainers.utility.DockerImageName; - -import static org.hamcrest.CoreMatchers.endsWith; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertThat; - - -public class MongoDBContainerTest { - - /** - * Taken from https://docs.mongodb.com - */ - @Test - public void shouldExecuteTransactions() { - try ( - // creatingMongoDBContainer { - final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:4.0.10")) - // } - ) { - - // startingMongoDBContainer { - mongoDBContainer.start(); - // } - - final String mongoRsUrl = mongoDBContainer.getReplicaSetUrl(); - assertNotNull(mongoRsUrl); - final MongoClient mongoSyncClient = MongoClients.create(mongoRsUrl); - 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)); - - 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); - assertEquals(trxResult, trxResultActual); - } catch (RuntimeException re) { - throw new IllegalStateException(re.getMessage(), re); - } finally { - clientSession.close(); - mongoSyncClient.close(); - } - } - } - - @Test - public void supportsMongoDB_4_4() { - try ( - final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:4.4")) - ) { - mongoDBContainer.start(); - } - } - - @Test - public void shouldTestDatabaseName() { - try ( - final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:4.0.10")) - ) { - mongoDBContainer.start(); - final String databaseName = "my-db"; - assertThat(mongoDBContainer.getReplicaSetUrl(databaseName), endsWith(databaseName)); - } - } -} 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 index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/mongodb/src/test/resources/logback-test.xml +++ b/modules/mongodb/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + diff --git a/modules/mssqlserver/build.gradle b/modules/mssqlserver/build.gradle index c36f1e6ba84..cb3e7a5b55e 100644 --- a/modules/mssqlserver/build.gradle +++ b/modules/mssqlserver/build.gradle @@ -1,21 +1,17 @@ description = "Testcontainers :: MS SQL Server" dependencies { - annotationProcessor 'com.google.auto.service:auto-service:1.0-rc7' - compileOnly 'com.google.auto.service:auto-service:1.0-rc6' + api project(':testcontainers-jdbc') - compile project(':jdbc') + compileOnly project(':testcontainers-r2dbc') + compileOnly 'io.r2dbc:r2dbc-mssql:1.0.5.RELEASE' - compileOnly project(':r2dbc') - compileOnly 'io.r2dbc:r2dbc-mssql:0.8.5.RELEASE' + testImplementation project(':testcontainers-jdbc-test') + testImplementation 'com.microsoft.sqlserver:mssql-jdbc:13.4.0.jre11' - testCompile project(':jdbc-test') - testCompile 'com.microsoft.sqlserver:mssql-jdbc:9.1.1.jre8-preview' - - testCompile project(':r2dbc') - testCompile 'io.r2dbc:r2dbc-mssql:0.8.5.RELEASE' + testImplementation project(':testcontainers-r2dbc') + testRuntimeOnly 'io.r2dbc:r2dbc-mssql:1.0.5.RELEASE' // MSSQL's wait strategy requires the JDBC driver - testCompile testFixtures(project(':r2dbc')) - testCompile 'com.microsoft.sqlserver:mssql-jdbc:9.1.1.jre8-preview' + 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 index 59c3ed0de68..6a58b3488ff 100644 --- a/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainer.java +++ b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainer.java @@ -13,7 +13,8 @@ public class MSSQLR2DBCDatabaseContainer implements R2DBCDatabaseContainer { private final MSSQLServerContainer container; public static ConnectionFactoryOptions getOptions(MSSQLServerContainer container) { - ConnectionFactoryOptions options = ConnectionFactoryOptions.builder() + ConnectionFactoryOptions options = ConnectionFactoryOptions + .builder() .option(ConnectionFactoryOptions.DRIVER, MSSQLR2DBCDatabaseContainerProvider.DRIVER) .build(); @@ -22,7 +23,8 @@ public static ConnectionFactoryOptions getOptions(MSSQLServerContainer contai @Override public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { - return options.mutate() + 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 diff --git a/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerProvider.java b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerProvider.java index fe7ada669bd..f829ddb8f1f 100644 --- a/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerProvider.java +++ b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerProvider.java @@ -1,6 +1,5 @@ package org.testcontainers.containers; -import com.google.auto.service.AutoService; import io.r2dbc.mssql.MssqlConnectionFactoryProvider; import io.r2dbc.spi.ConnectionFactoryMetadata; import io.r2dbc.spi.ConnectionFactoryOptions; @@ -9,7 +8,6 @@ import javax.annotation.Nullable; -@AutoService(R2DBCDatabaseContainerProvider.class) public class MSSQLR2DBCDatabaseContainerProvider implements R2DBCDatabaseContainerProvider { static final String DRIVER = MssqlConnectionFactoryProvider.MSSQL_DRIVER; diff --git a/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLServerContainer.java b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLServerContainer.java index a20c60fd2dc..07f8a064c00 100644 --- a/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLServerContainer.java +++ b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLServerContainer.java @@ -3,15 +3,24 @@ import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.LicenseAcceptance; +import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Stream; /** - * @author Stefan Hufschmidt + * 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"; @@ -21,24 +30,25 @@ public class MSSQLServerContainer> exten public static final Integer MS_SQL_SERVER_PORT = 1433; - static final String DEFAULT_USER = "SA"; + 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[]{ + 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) + Pattern.compile("[^a-zA-Z0-9]+", Pattern.CASE_INSENSITIVE), }; /** - * @deprecated use {@link MSSQLServerContainer(DockerImageName)} instead + * @deprecated use {@link #MSSQLServerContainer(DockerImageName)} instead */ @Deprecated public MSSQLServerContainer() { @@ -51,7 +61,6 @@ public MSSQLServerContainer(final String dockerImageName) { public MSSQLServerContainer(final DockerImageName dockerImageName) { super(dockerImageName); - dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); withStartupTimeoutSeconds(DEFAULT_STARTUP_TIMEOUT_SECONDS); @@ -60,19 +69,19 @@ public MSSQLServerContainer(final DockerImageName dockerImageName) { } @Override - protected Integer getLivenessCheckPort() { - return getMappedPort(MS_SQL_SERVER_PORT); + public Set getLivenessCheckPortNumbers() { + return super.getLivenessCheckPortNumbers(); } @Override protected void configure() { - // If license was not accepted programatically, check if it was accepted via resource file + // 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("SA_PASSWORD", password); + addEnv("MSSQL_SA_PASSWORD", password); } /** @@ -89,6 +98,17 @@ 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(";", ";"); @@ -118,7 +138,6 @@ public SELF withPassword(final String password) { } private void checkPasswordStrength(String password) { - if (password == null) { throw new IllegalArgumentException("Null password is not allowed"); } @@ -131,18 +150,19 @@ private void checkPasswordStrength(String password) { throw new IllegalArgumentException("Password can be up to 128 characters long"); } - long satisfiedCategories = Stream.of(PASSWORD_CATEGORY_VALIDATION_PATTERNS) + 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 (%)." + " - 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 index c3495c8a3a6..27e8ab75225 100644 --- a/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLServerContainerProvider.java +++ b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLServerContainerProvider.java @@ -6,6 +6,7 @@ * Factory for MS SQL Server containers. */ public class MSSQLServerContainerProvider extends JdbcDatabaseContainerProvider { + @Override public boolean supports(String databaseType) { return databaseType.equals(MSSQLServerContainer.NAME); 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.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 index 0cec042e19f..804691e2baf 100644 --- a/modules/mssqlserver/src/test/java/org/testcontainers/MSSQLServerTestImages.java +++ b/modules/mssqlserver/src/test/java/org/testcontainers/MSSQLServerTestImages.java @@ -3,5 +3,5 @@ import org.testcontainers.utility.DockerImageName; public interface MSSQLServerTestImages { - DockerImageName MSSQL_SERVER_IMAGE = DockerImageName.parse("mcr.microsoft.com/mssql/server:2017-CU12"); + 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 index 8c0c9ab1c2b..a0d3106f9c6 100644 --- a/modules/mssqlserver/src/test/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerTest.java +++ b/modules/mssqlserver/src/test/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerTest.java @@ -13,7 +13,7 @@ protected ConnectionFactoryOptions getOptions(MSSQLServerContainer container) @Override protected String createR2DBCUrl() { - return "r2dbc:tc:sqlserver:///?TC_IMAGE_TAG=2017-CU12"; + return "r2dbc:tc:sqlserver:///?TC_IMAGE_TAG=2022-CU14-ubuntu-22.04"; } @Override 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 index 00e22199b46..9f616ab10f8 100644 --- a/modules/mssqlserver/src/test/java/org/testcontainers/jdbc/mssqlserver/MSSQLServerJDBCDriverTest.java +++ b/modules/mssqlserver/src/test/java/org/testcontainers/jdbc/mssqlserver/MSSQLServerJDBCDriverTest.java @@ -1,21 +1,20 @@ package org.testcontainers.jdbc.mssqlserver; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; import org.testcontainers.jdbc.AbstractJDBCDriverTest; +import java.util.Arrays; import java.util.EnumSet; -import static java.util.Arrays.asList; +class MSSQLServerJDBCDriverTest extends AbstractJDBCDriverTest { -@RunWith(Parameterized.class) -public class MSSQLServerJDBCDriverTest extends AbstractJDBCDriverTest { - - @Parameterized.Parameters(name = "{index} - {0}") public static Iterable data() { - return asList( - new Object[][]{ - {"jdbc:tc:sqlserver:2017-CU12://hostname:hostport;databaseName=databasename", EnumSet.noneOf(Options.class)}, - }); + 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/junit/mssqlserver/CustomPasswordMSSQLServerTest.java b/modules/mssqlserver/src/test/java/org/testcontainers/junit/mssqlserver/CustomPasswordMSSQLServerTest.java deleted file mode 100644 index 3df8ff0fa3d..00000000000 --- a/modules/mssqlserver/src/test/java/org/testcontainers/junit/mssqlserver/CustomPasswordMSSQLServerTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package org.testcontainers.junit.mssqlserver; - -import org.apache.commons.lang.RandomStringUtils; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.testcontainers.MSSQLServerTestImages; -import org.testcontainers.containers.MSSQLServerContainer; - -import java.util.Arrays; -import java.util.Collection; - -import static org.rnorth.visibleassertions.VisibleAssertions.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 - * - * @author Enrico Costanzi - */ -@RunWith(Parameterized.class) -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 = "_(!)_"; - - private String password; - private Boolean valid; - - public CustomPasswordMSSQLServerTest(String password, Boolean valid) { - this.password = password; - this.valid = valid; - } - - @Parameterized.Parameters - public static Collection data() { - return Arrays.asList(new Object[][]{ - new Object[]{null, false}, - // too short - {"abc123", false}, - - // too long - {RandomStringUtils.randomAlphabetic(129), false}, - - // only 2 categories - {UPPER_CASE_LETTERS + NUMBERS, false}, - {UPPER_CASE_LETTERS + SPECIAL_CHARS, false}, - {LOWER_CASE_LETTERS + NUMBERS, false}, - {LOWER_CASE_LETTERS + SPECIAL_CHARS, false}, - {NUMBERS + SPECIAL_CHARS, false}, - - // 3 categories - {UPPER_CASE_LETTERS + LOWER_CASE_LETTERS + NUMBERS, true}, - {UPPER_CASE_LETTERS + LOWER_CASE_LETTERS + SPECIAL_CHARS, true}, - {UPPER_CASE_LETTERS + NUMBERS + SPECIAL_CHARS, true}, - {LOWER_CASE_LETTERS + NUMBERS + SPECIAL_CHARS, true}, - - // 4 categories - {UPPER_CASE_LETTERS + LOWER_CASE_LETTERS + NUMBERS + SPECIAL_CHARS, true}, - - - }); - } - - @Test - public void runPasswordTests() { - try { - new MSSQLServerContainer<>(MSSQLServerTestImages.MSSQL_SERVER_IMAGE).withPassword(this.password); - if (!valid) - fail("Password " + this.password + " is not valid. Expected exception"); - } catch (IllegalArgumentException e) { - if (valid) - fail("Password " + this.password + " should have been validated"); - } - } - - -} diff --git a/modules/mssqlserver/src/test/java/org/testcontainers/junit/mssqlserver/CustomizableMSSQLServerTest.java b/modules/mssqlserver/src/test/java/org/testcontainers/junit/mssqlserver/CustomizableMSSQLServerTest.java deleted file mode 100644 index d3c387e7b4a..00000000000 --- a/modules/mssqlserver/src/test/java/org/testcontainers/junit/mssqlserver/CustomizableMSSQLServerTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.testcontainers.junit.mssqlserver; - -import org.junit.Test; -import org.testcontainers.containers.MSSQLServerContainer; -import org.testcontainers.db.AbstractContainerDatabaseTest; -import org.testcontainers.utility.DockerImageName; - -import java.sql.ResultSet; -import java.sql.SQLException; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; - -public class CustomizableMSSQLServerTest extends AbstractContainerDatabaseTest { - - private static final String STRONG_PASSWORD = "myStrong(!)Password"; - - @Test - public void testSqlServerConnection() throws SQLException { - try (MSSQLServerContainer mssqlServerContainer = new MSSQLServerContainer<>(DockerImageName.parse("mcr.microsoft.com/mssql/server:2017-CU12")) - .withPassword(STRONG_PASSWORD)) { - - mssqlServerContainer.start(); - - ResultSet resultSet = performQuery(mssqlServerContainer, mssqlServerContainer.getTestQueryString()); - int resultSetInt = resultSet.getInt(1); - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } - } -} diff --git a/modules/mssqlserver/src/test/java/org/testcontainers/junit/mssqlserver/SimpleMSSQLServerTest.java b/modules/mssqlserver/src/test/java/org/testcontainers/junit/mssqlserver/SimpleMSSQLServerTest.java deleted file mode 100644 index 100b29b34f7..00000000000 --- a/modules/mssqlserver/src/test/java/org/testcontainers/junit/mssqlserver/SimpleMSSQLServerTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package org.testcontainers.junit.mssqlserver; - -import org.junit.Test; -import org.testcontainers.MSSQLServerTestImages; -import org.testcontainers.containers.MSSQLServerContainer; -import org.testcontainers.db.AbstractContainerDatabaseTest; - -import javax.sql.DataSource; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; - -import static org.hamcrest.CoreMatchers.containsString; -import static org.junit.Assert.assertThat; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.testcontainers.MSSQLServerTestImages.MSSQL_SERVER_IMAGE; - -public class SimpleMSSQLServerTest extends AbstractContainerDatabaseTest { - - @Test - public void testSimple() throws SQLException { - try (MSSQLServerContainer mssqlServer = new MSSQLServerContainer<>(MSSQL_SERVER_IMAGE)) { - mssqlServer.start(); - ResultSet resultSet = performQuery(mssqlServer, "SELECT 1"); - - int resultSetInt = resultSet.getInt(1); - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } - } - - @Test - public void testWithAdditionalUrlParamInJdbcUrl() { - try (MSSQLServerContainer mssqlServer = new MSSQLServerContainer<>(MSSQL_SERVER_IMAGE) - .withUrlParam("integratedSecurity", "false") - .withUrlParam("applicationName", "MyApp")) { - - mssqlServer.start(); - - String jdbcUrl = mssqlServer.getJdbcUrl(); - assertThat(jdbcUrl, containsString(";integratedSecurity=false;applicationName=MyApp")); - } - } - - - @Test - public void testSetupDatabase() throws SQLException { - try (MSSQLServerContainer mssqlServer = new MSSQLServerContainer<>(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"); - assertEquals("A basic SELECT query succeeds", 3, resultSetInt); - } - } -} 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 index 8a704b9c066..5ae6ecbd4d1 100644 --- a/modules/mssqlserver/src/test/resources/container-license-acceptance.txt +++ b/modules/mssqlserver/src/test/resources/container-license-acceptance.txt @@ -1 +1 @@ -mcr.microsoft.com/mssql/server:2017-CU12 +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 index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/mssqlserver/src/test/resources/logback-test.xml +++ b/modules/mssqlserver/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + diff --git a/modules/mysql/build.gradle b/modules/mysql/build.gradle index da3dad9f2b3..2cf8b17d64f 100644 --- a/modules/mysql/build.gradle +++ b/modules/mysql/build.gradle @@ -1,19 +1,16 @@ description = "Testcontainers :: JDBC :: MySQL" dependencies { - annotationProcessor 'com.google.auto.service:auto-service:1.0-rc7' - compileOnly 'com.google.auto.service:auto-service:1.0-rc6' + api project(':testcontainers-jdbc') - compile project(':jdbc') + compileOnly project(':testcontainers-r2dbc') + compileOnly 'io.asyncer:r2dbc-mysql:1.4.1' - compileOnly project(':r2dbc') - compileOnly 'dev.miku:r2dbc-mysql:0.8.2.RELEASE' + testImplementation project(':testcontainers-jdbc-test') + testRuntimeOnly 'com.mysql:mysql-connector-j:9.6.0' - testCompile project(':jdbc-test') - testCompile 'mysql:mysql-connector-java:8.0.22' + testImplementation testFixtures(project(':testcontainers-r2dbc')) + testRuntimeOnly 'io.asyncer:r2dbc-mysql:1.4.1' - testCompile testFixtures(project(':r2dbc')) - testCompile 'dev.miku:r2dbc-mysql:0.8.1.RELEASE' - - compileOnly 'org.jetbrains:annotations:20.1.0' + compileOnly 'org.jetbrains:annotations:26.1.0' } 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 1dc235e6536..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,14 +1,21 @@ package org.testcontainers.containers; import org.jetbrains.annotations.NotNull; +import org.testcontainers.images.builder.Transferable; import org.testcontainers.utility.DockerImageName; -import java.util.HashSet; 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"; @@ -16,7 +23,7 @@ public class MySQLContainer> extends JdbcDatab private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("mysql"); @Deprecated - public static final String DEFAULT_TAG = "5.7.22"; + public static final String DEFAULT_TAG = "5.7.34"; @Deprecated public static final String IMAGE = DEFAULT_IMAGE_NAME.getUnversionedPart(); @@ -26,14 +33,19 @@ public class MySQLContainer> extends JdbcDatab 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"; /** - * @deprecated use {@link MySQLContainer(DockerImageName)} instead + * @deprecated use {@link #MySQLContainer(DockerImageName)} instead */ @Deprecated public MySQLContainer() { @@ -46,26 +58,35 @@ public MySQLContainer(String 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 new HashSet<>(getMappedPort(MYSQL_PORT)); + return super.getLivenessCheckPorts(); } @Override protected void configure() { - optionallyMapResourceParameterAsVolume(MY_CNF_CONFIG_OVERRIDE_PARAM_NAME, "/etc/mysql/conf.d", - "mysql-default-conf"); + optionallyMapResourceParameterAsVolume( + MY_CNF_CONFIG_OVERRIDE_PARAM_NAME, + "/etc/mysql/conf.d", + "mysql-default-conf", + Transferable.DEFAULT_DIR_MODE + ); addEnv("MYSQL_DATABASE", databaseName); - addEnv("MYSQL_USER", username); + if (!MYSQL_ROOT_USER.equalsIgnoreCase(username)) { + addEnv("MYSQL_USER", username); + } if (password != null && !password.isEmpty()) { addEnv("MYSQL_PASSWORD", password); addEnv("MYSQL_ROOT_PASSWORD", password); @@ -90,20 +111,19 @@ public String getDriverClassName() { @Override public String getJdbcUrl() { String additionalUrlParams = constructUrlParameters("?", "&"); - return "jdbc:mysql://" + getHost() + ":" + getMappedPort(MYSQL_PORT) + - "/" + databaseName + additionalUrlParams; + return "jdbc:mysql://" + getHost() + ":" + getMappedPort(MYSQL_PORT) + "/" + databaseName + additionalUrlParams; } @Override protected String constructUrlForConnection(String queryString) { String url = super.constructUrlForConnection(queryString); - if (! url.contains("useSSL=")) { + if (!url.contains("useSSL=")) { String separator = url.contains("?") ? "&" : "?"; url = url + separator + "useSSL=false"; } - if (! url.contains("allowPublicKeyRetrieval=")) { + if (!url.contains("allowPublicKeyRetrieval=")) { url = url + "&allowPublicKeyRetrieval=true"; } 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 18d126635b1..621001d7b57 100644 --- a/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainerProvider.java +++ b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainerProvider.java @@ -35,5 +35,4 @@ public JdbcDatabaseContainer newInstance(String tag) { 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 index 274224352a8..4bbb542fec8 100644 --- a/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainer.java +++ b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainer.java @@ -13,7 +13,8 @@ public class MySQLR2DBCDatabaseContainer implements R2DBCDatabaseContainer { private final MySQLContainer container; public static ConnectionFactoryOptions getOptions(MySQLContainer container) { - ConnectionFactoryOptions options = ConnectionFactoryOptions.builder() + ConnectionFactoryOptions options = ConnectionFactoryOptions + .builder() .option(ConnectionFactoryOptions.DRIVER, MySQLR2DBCDatabaseContainerProvider.DRIVER) .build(); @@ -22,7 +23,8 @@ public static ConnectionFactoryOptions getOptions(MySQLContainer container) { @Override public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { - return options.mutate() + return options + .mutate() .option(ConnectionFactoryOptions.HOST, container.getHost()) .option(ConnectionFactoryOptions.PORT, container.getMappedPort(MySQLContainer.MYSQL_PORT)) .option(ConnectionFactoryOptions.DATABASE, container.getDatabaseName()) diff --git a/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerProvider.java b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerProvider.java index ec3b6a4e41f..97f7f4a243d 100644 --- a/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerProvider.java +++ b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerProvider.java @@ -1,7 +1,6 @@ package org.testcontainers.containers; -import com.google.auto.service.AutoService; -import dev.miku.r2dbc.mysql.MySqlConnectionFactoryProvider; +import io.asyncer.r2dbc.mysql.MySqlConnectionFactoryProvider; import io.r2dbc.spi.ConnectionFactoryMetadata; import io.r2dbc.spi.ConnectionFactoryOptions; import org.testcontainers.r2dbc.R2DBCDatabaseContainer; @@ -9,7 +8,6 @@ import javax.annotation.Nullable; -@AutoService(R2DBCDatabaseContainerProvider.class) public class MySQLR2DBCDatabaseContainerProvider implements R2DBCDatabaseContainerProvider { static final String DRIVER = MySqlConnectionFactoryProvider.MYSQL_DRIVER; @@ -23,7 +21,7 @@ public boolean supports(ConnectionFactoryOptions options) { public R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options) { String image = MySQLContainer.IMAGE + ":" + options.getRequiredValue(IMAGE_TAG_OPTION); MySQLContainer container = new MySQLContainer<>(image) - .withDatabaseName(options.getRequiredValue(ConnectionFactoryOptions.DATABASE)); + .withDatabaseName((String) options.getRequiredValue(ConnectionFactoryOptions.DATABASE)); if (Boolean.TRUE.equals(options.getValue(REUSABLE_OPTION))) { container.withReuse(true); 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 d608cb9c923..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,7 +11,7 @@ sort_buffer_size = 64K read_buffer_size = 256K read_rnd_buffer_size = 256K net_buffer_length = 2K -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, @@ -43,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 index 6c8c6690d1f..6b3fe80ce4a 100644 --- a/modules/mysql/src/test/java/org/testcontainers/MySQLTestImages.java +++ b/modules/mysql/src/test/java/org/testcontainers/MySQLTestImages.java @@ -3,7 +3,12 @@ import org.testcontainers.utility.DockerImageName; public class MySQLTestImages { - public static final DockerImageName MYSQL_IMAGE = DockerImageName.parse("mysql:5.7.22"); - public static final DockerImageName MYSQL_55_IMAGE = DockerImageName.parse("mysql:5.5"); - public static final DockerImageName MYSQL_56_IMAGE = DockerImageName.parse("mysql:5.6"); + + 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 index 38abf637c3a..42a852ad1f1 100644 --- a/modules/mysql/src/test/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerTest.java +++ b/modules/mysql/src/test/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerTest.java @@ -13,12 +13,11 @@ protected ConnectionFactoryOptions getOptions(MySQLContainer container) { @Override protected String createR2DBCUrl() { - return "r2dbc:tc:mysql:///db?TC_IMAGE_TAG=5.7.22"; + return "r2dbc:tc:mysql:///db?TC_IMAGE_TAG=" + MySQLTestImages.MYSQL_80_IMAGE.getVersionPart(); } @Override protected MySQLContainer createContainer() { - return new MySQLContainer<>(MySQLTestImages.MYSQL_IMAGE); + 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/mysql/src/test/java/org/testcontainers/jdbc/mysql/JDBCDriverWithPoolTest.java b/modules/mysql/src/test/java/org/testcontainers/jdbc/mysql/JDBCDriverWithPoolTest.java index 1ad77264c62..0075b2ebf7f 100644 --- a/modules/mysql/src/test/java/org/testcontainers/jdbc/mysql/JDBCDriverWithPoolTest.java +++ b/modules/mysql/src/test/java/org/testcontainers/jdbc/mysql/JDBCDriverWithPoolTest.java @@ -5,22 +5,23 @@ 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}. @@ -28,15 +29,17 @@ * 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:5://hostname/databasename?TC_INITFUNCTION=org.testcontainers.jdbc.mysql.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( + public static Stream> dataSourceSuppliers() { + return Stream.of( JDBCDriverWithPoolTest::getTomcatDataSourceWithDriverClassName, JDBCDriverWithPoolTest::getTomcatDataSource, JDBCDriverWithPoolTest::getHikariDataSourceWithDriverClassName, @@ -53,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(); } @@ -72,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 @@ -134,7 +142,7 @@ 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.setUsername("any"); // Recent versions of Vibur require a username, even though it will not be used ds.setPassword(""); ds.setPoolInitialSize(3); ds.setPoolMaxSize(10); @@ -149,7 +157,7 @@ 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.setUsername("any"); // Recent versions of Vibur require a username, even though it will not be used ds.setPassword(""); ds.setPoolInitialSize(3); ds.setPoolMaxSize(10); @@ -162,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 index d4b37b291d5..b069e5fd0c8 100644 --- a/modules/mysql/src/test/java/org/testcontainers/jdbc/mysql/MySQLJDBCDriverTest.java +++ b/modules/mysql/src/test/java/org/testcontainers/jdbc/mysql/MySQLJDBCDriverTest.java @@ -1,30 +1,47 @@ package org.testcontainers.jdbc.mysql; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; import org.testcontainers.jdbc.AbstractJDBCDriverTest; +import java.util.Arrays; import java.util.EnumSet; -import static java.util.Arrays.asList; +class MySQLJDBCDriverTest extends AbstractJDBCDriverTest { -@RunWith(Parameterized.class) -public class MySQLJDBCDriverTest extends AbstractJDBCDriverTest { - - @Parameterized.Parameters(name = "{index} - {0}") public static Iterable data() { - return 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:5.5.43://hostname/databasename?user=someuser&TC_INITFUNCTION=org.testcontainers.jdbc.AbstractJDBCDriverTest::sampleInitFunction", EnumSet.of(Options.ScriptedSchema, Options.JDBCParams)}, - {"jdbc:tc:mysql:5.5.43://hostname/databasename?user=someuser&password=somepwd&TC_INITSCRIPT=somepath/init_mysql.sql", EnumSet.of(Options.ScriptedSchema, Options.JDBCParams)}, - {"jdbc:tc:mysql:5.5.43://hostname/databasename?user=someuser&password=somepwd&TC_INITSCRIPT=file:sql/init_mysql.sql", EnumSet.of(Options.ScriptedSchema, Options.JDBCParams)}, - {"jdbc:tc:mysql:5.5.43://hostname/databasename?user=someuser&password=somepwd&TC_INITFUNCTION=org.testcontainers.jdbc.AbstractJDBCDriverTest::sampleInitFunction", EnumSet.of(Options.ScriptedSchema, Options.JDBCParams)}, - {"jdbc:tc:mysql:5.5.43://hostname/databasename?TC_INITSCRIPT=somepath/init_unicode_mysql.sql&useUnicode=yes&characterEncoding=utf8", EnumSet.of(Options.CharacterSet)}, - {"jdbc:tc:mysql:5.5.43://hostname/databasename", EnumSet.noneOf(Options.class)}, - {"jdbc:tc:mysql:5.5.43://hostname/databasename?useSSL=false", EnumSet.noneOf(Options.class)}, - {"jdbc:tc:mysql:5.6://hostname/databasename?TC_MY_CNF=somepath/mysql_conf_override", EnumSet.of(Options.CustomIniFile)}, - }); + 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/junit/mysql/CustomizableMysqlTest.java b/modules/mysql/src/test/java/org/testcontainers/junit/mysql/CustomizableMysqlTest.java deleted file mode 100644 index 708b2b987cd..00000000000 --- a/modules/mysql/src/test/java/org/testcontainers/junit/mysql/CustomizableMysqlTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.testcontainers.junit.mysql; - -import org.junit.Test; -import org.testcontainers.MySQLTestImages; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.db.AbstractContainerDatabaseTest; - -import java.sql.ResultSet; -import java.sql.SQLException; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; - -public class CustomizableMysqlTest extends AbstractContainerDatabaseTest { - private static final String DB_NAME = "foo"; - private static final String USER = "bar"; - private static final String PWD = "baz"; - - @Test - public void testSimple() 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_IMAGE) - .withDatabaseName(DB_NAME) - .withUsername(USER) - .withPassword(PWD) - .withEnv("MYSQL_ROOT_HOST", "%")) { - - mysql.start(); - - ResultSet resultSet = performQuery(mysql, "SELECT 1"); - - int resultSetInt = resultSet.getInt(1); - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } - } -} diff --git a/modules/mysql/src/test/java/org/testcontainers/junit/mysql/MultiVersionMySQLTest.java b/modules/mysql/src/test/java/org/testcontainers/junit/mysql/MultiVersionMySQLTest.java deleted file mode 100644 index b8f1a136d9a..00000000000 --- a/modules/mysql/src/test/java/org/testcontainers/junit/mysql/MultiVersionMySQLTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.testcontainers.junit.mysql; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.testcontainers.MySQLTestImages; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.db.AbstractContainerDatabaseTest; -import org.testcontainers.utility.DockerImageName; - -import java.sql.ResultSet; -import java.sql.SQLException; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; - -@RunWith(Parameterized.class) -public class MultiVersionMySQLTest extends AbstractContainerDatabaseTest { - - @Parameterized.Parameters(name = "{0}") - public static String[] params() { - return new String[]{"5.5.62", "5.6.42", "5.7.26", "8.0.16"}; - } - - private final String version; - - public MultiVersionMySQLTest(String version) { - this.version = version; - } - - @Test - public void versionCheckTest() throws SQLException { - - final DockerImageName dockerImageName = MySQLTestImages.MYSQL_IMAGE.withTag(version); - - try (MySQLContainer mysql = new MySQLContainer<>(dockerImageName)) { - mysql.start(); - final ResultSet resultSet = performQuery(mysql, "SELECT VERSION()"); - final String resultSetString = resultSet.getString(1); - - assertEquals("The database version can be set using a container rule parameter", version, resultSetString); - } - } -} diff --git a/modules/mysql/src/test/java/org/testcontainers/junit/mysql/SimpleMySQLTest.java b/modules/mysql/src/test/java/org/testcontainers/junit/mysql/SimpleMySQLTest.java deleted file mode 100644 index f0ceeb92a48..00000000000 --- a/modules/mysql/src/test/java/org/testcontainers/junit/mysql/SimpleMySQLTest.java +++ /dev/null @@ -1,229 +0,0 @@ -package org.testcontainers.junit.mysql; - -import org.apache.commons.lang.SystemUtils; -import org.junit.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.testcontainers.containers.ContainerLaunchException; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.containers.output.Slf4jLogConsumer; -import org.testcontainers.db.AbstractContainerDatabaseTest; - -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 static org.hamcrest.CoreMatchers.containsString; -import static org.junit.Assert.assertThat; -import static org.junit.Assume.assumeFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; -import static org.rnorth.visibleassertions.VisibleAssertions.fail; -import static org.testcontainers.MySQLTestImages.MYSQL_55_IMAGE; -import static org.testcontainers.MySQLTestImages.MYSQL_56_IMAGE; -import static org.testcontainers.MySQLTestImages.MYSQL_IMAGE; - - -public class SimpleMySQLTest extends AbstractContainerDatabaseTest { - - 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<>(MYSQL_IMAGE); - - @ClassRule - public static MySQLContainer mysqlOldVersion = new MySQLContainer<>(DockerImageName.parse("mysql:5.5");) - - @ClassRule - public static MySQLContainer mysqlCustomConfig = new MySQLContainer<>(DockerImageName.parse("mysql:5.6")) - .withConfigurationOverride("somepath/mysql_conf_override"); - */ - - @Test - public void testSimple() throws SQLException { - try (MySQLContainer mysql = new MySQLContainer<>(MYSQL_IMAGE) - .withConfigurationOverride("somepath/mysql_conf_override") - .withLogConsumer(new Slf4jLogConsumer(logger))) { - - mysql.start(); - - ResultSet resultSet = performQuery(mysql, "SELECT 1"); - int resultSetInt = resultSet.getInt(1); - - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } - } - - @Test - public void testSpecificVersion() throws SQLException { - try (MySQLContainer mysqlOldVersion = new MySQLContainer<>(MYSQL_55_IMAGE) - .withConfigurationOverride("somepath/mysql_conf_override") - .withLogConsumer(new Slf4jLogConsumer(logger))) { - - mysqlOldVersion.start(); - - 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")); - } - } - - @Test - public void testMySQLWithCustomIniFile() throws SQLException { - assumeFalse(SystemUtils.IS_OS_WINDOWS); - - try (MySQLContainer mysqlCustomConfig = new MySQLContainer<>(MYSQL_56_IMAGE) - .withConfigurationOverride("somepath/mysql_conf_override")) { - - mysqlCustomConfig.start(); - - 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); - } - } - - @Test - public void testCommandOverride() throws SQLException { - try (MySQLContainer mysqlCustomConfig = new MySQLContainer<>(MYSQL_IMAGE) - .withCommand("mysqld --auto_increment_increment=42")) { - - mysqlCustomConfig.start(); - - ResultSet resultSet = performQuery(mysqlCustomConfig, "show variables like 'auto_increment_increment'"); - String result = resultSet.getString("Value"); - - assertEquals("Auto increment increment should be overriden by command line", "42", result); - } - } - - @Test - public void testExplicitInitScript() throws SQLException { - try (MySQLContainer container = new MySQLContainer<>(MYSQL_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); - - assertEquals("Value from init script should equal real value", "hello world", firstColumnValue); - } - } - - @Test(expected = ContainerLaunchException.class) - public void testEmptyPasswordWithNonRootUser() { - try (MySQLContainer container = new MySQLContainer<>(MYSQL_55_IMAGE) - .withDatabaseName("TEST") - .withUsername("test") - .withPassword("") - .withEnv("MYSQL_ROOT_HOST", "%")){ - container.start(); - fail("ContainerLaunchException expected to be thrown"); - } - } - - @Test - public 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<>(MYSQL_55_IMAGE) - .withDatabaseName("foo") - .withUsername("root") - .withPassword("") - .withEnv("MYSQL_ROOT_HOST", "%")) { - - mysql.start(); - - ResultSet resultSet = performQuery(mysql, "SELECT 1"); - int resultSetInt = resultSet.getInt(1); - - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } - } - - @Test - public void testWithAdditionalUrlParamTimeZone() throws SQLException { - MySQLContainer mysql = new MySQLContainer<>(MYSQL_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); - assertTrue(message, actualDateTime.equals(expectedDateTime)); - } - } finally { - mysql.stop(); - } - } - - @Test - public void testWithAdditionalUrlParamMultiQueries() throws SQLException { - MySQLContainer mysql = new MySQLContainer<>(MYSQL_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); - assertEquals("Value from bar should equal real value", "hello world", firstColumnValue); - } - } finally { - mysql.stop(); - } - } - - @Test - public void testWithAdditionalUrlParamInJdbcUrl() { - MySQLContainer mysql = new MySQLContainer<>(MYSQL_IMAGE) - .withUrlParam("allowMultiQueries", "true") - .withUrlParam("rewriteBatchedStatements", "true") - .withLogConsumer(new Slf4jLogConsumer(logger)); - - try { - mysql.start(); - String jdbcUrl = mysql.getJdbcUrl(); - assertThat(jdbcUrl, containsString("?")); - assertThat(jdbcUrl, containsString("&")); - assertThat(jdbcUrl, containsString("rewriteBatchedStatements=true")); - assertThat(jdbcUrl, containsString("allowMultiQueries=true")); - } finally { - mysql.stop(); - } - } -} 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 index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/mysql/src/test/resources/logback-test.xml +++ b/modules/mysql/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + diff --git a/modules/mysql/src/test/resources/somepath/mysql_conf_override/my.cnf b/modules/mysql/src/test/resources/somepath/mysql_conf_override/my.cnf index 78012cc4bfe..6e6dcd28ad1 100644 --- a/modules/mysql/src/test/resources/somepath/mysql_conf_override/my.cnf +++ b/modules/mysql/src/test/resources/somepath/mysql_conf_override/my.cnf @@ -1,5 +1,6 @@ [mysqld] user = mysql +datadir = /var/lib/mysql port = 3306 #socket = /tmp/mysql.sock skip-external-locking @@ -10,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. @@ -48,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/build.gradle b/modules/neo4j/build.gradle index 9aab6b18298..96f73b3bb81 100644 --- a/modules/neo4j/build.gradle +++ b/modules/neo4j/build.gradle @@ -1,4 +1,4 @@ -description = "TestContainers :: Neo4j" +description = "Testcontainers :: Neo4j" def generatedResourcesDir = new File(project.buildDir, "generated-resources") def customNeo4jPluginDestinationDir = new File(generatedResourcesDir, "custom-plugins") @@ -18,21 +18,20 @@ sourceSets { task customNeo4jPluginJar(type: Jar) { from sourceSets.customNeo4jPlugin.output - archiveName = "hello-world.jar" - destinationDir = customNeo4jPluginDestinationDir + archiveFileName = "hello-world.jar" + destinationDirectory = customNeo4jPluginDestinationDir inputs.files(sourceSets.customNeo4jPlugin.java.srcDirs) outputs.cacheIf { true } outputs.dir(customNeo4jPluginDestinationDir) } -test.dependsOn customNeo4jPluginJar +processTestResources.dependsOn customNeo4jPluginJar dependencies { - customNeo4jPluginCompileOnly "org.neo4j:neo4j:3.5.9" + customNeo4jPluginCompileOnly "org.neo4j:neo4j:3.5.35" - compile project(":testcontainers") + api project(":testcontainers") - testCompile "org.neo4j.driver:neo4j-java-driver:1.7.5" - testCompile 'org.assertj:assertj-core:3.18.1' + 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 index cbfcdc03a55..8948c98d53c 100644 --- 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 @@ -9,7 +9,6 @@ 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 index e93a840302a..f7fc0beacfe 100644 --- a/modules/neo4j/src/main/java/org/testcontainers/containers/Neo4jContainer.java +++ b/modules/neo4j/src/main/java/org/testcontainers/containers/Neo4jContainer.java @@ -1,25 +1,39 @@ package org.testcontainers.containers; -import static java.net.HttpURLConnection.HTTP_OK; -import static java.util.stream.Collectors.toSet; - -import java.time.Duration; -import java.util.Set; -import java.util.stream.Stream; 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; + /** - * Testcontainer for Neo4j. + * Testcontainers implementation for Neo4j. + *

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

+ * Exposed ports: + *

    + *
  • Bolt: 7687
  • + *
  • HTTP: 7474
  • + *
  • HTTPS: 7473
  • + *
* - * @param "SELF" to be used in the withXXX methods. - * @author Michael J. Simons + * @deprecated use {@link org.testcontainers.neo4j.Neo4jContainer} instead. */ +@Deprecated public class Neo4jContainer> extends GenericContainer { /** @@ -30,7 +44,8 @@ public class Neo4jContainer> extends GenericContaine /** * The default tag (version) to use. */ - private static final String DEFAULT_TAG = "3.5.0"; + private static final String DEFAULT_TAG = "4.4"; + private static final String ENTERPRISE_TAG = DEFAULT_TAG + "-enterprise"; /** @@ -59,9 +74,21 @@ public class Neo4jContainer> extends GenericContaine 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 use {@link #Neo4jContainer(DockerImageName)} instead */ @Deprecated public Neo4jContainer() { @@ -84,40 +111,80 @@ public Neo4jContainer(String dockerImageName) { */ public Neo4jContainer(final DockerImageName dockerImageName) { super(dockerImageName); - - this.standardImage = dockerImageName.getUnversionedPart() - .equals(DEFAULT_IMAGE_NAME.getUnversionedPart()); + this.standardImage = dockerImageName.getUnversionedPart().equals(DEFAULT_IMAGE_NAME.getUnversionedPart()); dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); - WaitStrategy waitForBolt = new LogMessageWaitStrategy() - .withRegEx(String.format(".*Bolt enabled on 0\\.0\\.0\\.0:%d\\.\n", DEFAULT_BOLT_PORT)); - WaitStrategy waitForHttp = new HttpWaitStrategy() - .forPort(DEFAULT_HTTP_PORT) - .forStatusCodeMatching(response -> response == HTTP_OK); - - this.waitStrategy = new WaitAllStrategy() - .withStrategy(waitForBolt) - .withStrategy(waitForHttp) - .withStartupTimeout(Duration.ofMinutes(2)); + 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) + return Stream + .of(DEFAULT_BOLT_PORT, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT) .map(this::getMappedPort) - .collect(toSet()); + .collect(Collectors.toSet()); } @Override protected void configure() { + configureAuth(); + configureLabsPlugins(); + configureWaitStrategy(); + } - boolean emptyAdminPassword = this.adminPassword == null || this.adminPassword.isEmpty(); - String neo4jAuth = emptyAdminPassword ? "none" : String.format(AUTH_FORMAT, this.adminPassword); - addEnv("NEO4J_AUTH", neo4jAuth); + /** + * 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)); + } } /** @@ -153,8 +220,8 @@ public String getHttpsUrl() { public S withEnterpriseEdition() { if (!standardImage) { throw new IllegalStateException( - String.format("Cannot use enterprise version with alternative image %s.", - getDockerImageName())); + String.format("Cannot use enterprise version with alternative image %s.", getDockerImageName()) + ); } setDockerImageName(DEFAULT_IMAGE_NAME.withTag(ENTERPRISE_TAG).asCanonicalNameString()); @@ -173,7 +240,9 @@ public S withEnterpriseEdition() { * @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(); } @@ -194,6 +263,8 @@ public S withoutAuthentication() { * 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
@@ -202,9 +273,15 @@ public S withoutAuthentication() {
      * 
* * @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"); } @@ -231,7 +308,6 @@ public S withPlugins(MountableFile plugins) { * @return This container. */ public S withNeo4jConfig(String key, String value) { - addEnv(formatConfigurationKey(key), value); return self(); } @@ -243,11 +319,51 @@ 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("\\.", "_")); + 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/containers/Neo4jContainerJUnitIntegrationTest.java b/modules/neo4j/src/test/java/org/testcontainers/containers/Neo4jContainerJUnitIntegrationTest.java deleted file mode 100644 index 5a67674cbe8..00000000000 --- a/modules/neo4j/src/test/java/org/testcontainers/containers/Neo4jContainerJUnitIntegrationTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.testcontainers.containers; - -import org.junit.ClassRule; -import org.junit.Test; -import org.neo4j.driver.v1.AuthTokens; -import org.neo4j.driver.v1.Driver; -import org.neo4j.driver.v1.GraphDatabase; -import org.neo4j.driver.v1.Session; - -import java.util.Collections; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.fail; - -/** - * Test for basic functionality when used as a @ClassRule. - * - * @author Michael J. Simons - */ -public class Neo4jContainerJUnitIntegrationTest { - - @ClassRule - public static Neo4jContainer neo4jContainer = new Neo4jContainer<>(Neo4jTestImages.NEO4J_TEST_IMAGE); - - @Test - public void shouldStart() { - - boolean actual = neo4jContainer.isRunning(); - assertThat(actual).isTrue(); - - try (Driver driver = GraphDatabase - .driver(neo4jContainer.getBoltUrl(), AuthTokens.basic("neo4j", "password")); - 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 - public void shouldReturnBoltUrl() { - String actual = neo4jContainer.getBoltUrl(); - - assertThat(actual).isNotNull(); - assertThat(actual).startsWith("bolt://"); - } - - @Test - public void shouldReturnHttpUrl() { - String actual = neo4jContainer.getHttpUrl(); - - assertThat(actual).isNotNull(); - assertThat(actual).startsWith("http://"); - } -} diff --git a/modules/neo4j/src/test/java/org/testcontainers/containers/Neo4jContainerTest.java b/modules/neo4j/src/test/java/org/testcontainers/containers/Neo4jContainerTest.java deleted file mode 100644 index 6a32dac0af0..00000000000 --- a/modules/neo4j/src/test/java/org/testcontainers/containers/Neo4jContainerTest.java +++ /dev/null @@ -1,156 +0,0 @@ -package org.testcontainers.containers; - -import org.junit.Test; -import org.neo4j.driver.v1.AuthToken; -import org.neo4j.driver.v1.AuthTokens; -import org.neo4j.driver.v1.Driver; -import org.neo4j.driver.v1.GraphDatabase; -import org.neo4j.driver.v1.Record; -import org.neo4j.driver.v1.Session; -import org.neo4j.driver.v1.StatementResult; -import org.testcontainers.utility.MountableFile; - -import java.util.Collections; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.assertj.core.api.Assumptions.assumeThat; - -/** - * Tests of functionality special to the Neo4jContainer. - * - * @author Michael J. Simons - */ -public class Neo4jContainerTest { - - // See org.testcontainers.utility.LicenseAcceptance#ACCEPTANCE_FILE_NAME - private static final String ACCEPTANCE_FILE_LOCATION = "/container-license-acceptance.txt"; - - @Test - public void shouldDisableAuthentication() { - - try ( - Neo4jContainer neo4jContainer = new Neo4jContainer<>(Neo4jTestImages.NEO4J_TEST_IMAGE).withoutAuthentication() - ) { - neo4jContainer.start(); - try (Driver driver = getDriver(neo4jContainer); - Session session = driver.session() - ) { - long one = session.run("RETURN 1", Collections.emptyMap()).next().get(0).asLong(); - assertThat(one).isEqualTo(1L); - } - } - } - - @Test - public void shouldCopyDatabase() { - try ( - Neo4jContainer neo4jContainer = new Neo4jContainer<>(Neo4jTestImages.NEO4J_TEST_IMAGE) - .withDatabase(MountableFile.forClasspathResource("/test-graph.db")) - ) { - neo4jContainer.start(); - try ( - Driver driver = getDriver(neo4jContainer); - Session session = driver.session() - ) { - StatementResult 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 - public void shouldCopyPlugins() { - try ( - Neo4jContainer neo4jContainer = new Neo4jContainer<>(Neo4jTestImages.NEO4J_TEST_IMAGE) - .withPlugins(MountableFile.forClasspathResource("/custom-plugins")) - ) { - neo4jContainer.start(); - try ( - Driver driver = getDriver(neo4jContainer); - Session session = driver.session() - ) { - assertThatCustomPluginWasCopied(session); - } - } - } - - @Test - public void shouldCopyPlugin() { - try ( - Neo4jContainer neo4jContainer = new Neo4jContainer<>(Neo4jTestImages.NEO4J_TEST_IMAGE) - .withPlugins(MountableFile.forClasspathResource("/custom-plugins/hello-world.jar")) - ) { - neo4jContainer.start(); - try ( - Driver driver = getDriver(neo4jContainer); - Session session = driver.session() - ) { - assertThatCustomPluginWasCopied(session); - } - } - } - - private static void assertThatCustomPluginWasCopied(Session session) { - StatementResult 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 - public void shouldCheckEnterpriseLicense() { - assumeThat(Neo4jContainerTest.class.getResource(ACCEPTANCE_FILE_LOCATION)).isNull(); - - String expectedImageName = "neo4j:3.5.0-enterprise"; - - assertThatExceptionOfType(IllegalStateException.class) - .isThrownBy(() -> new Neo4jContainer<>(Neo4jTestImages.NEO4J_TEST_IMAGE).withEnterpriseEdition()) - .withMessageContaining("The image " + expectedImageName + " requires you to accept a license agreement."); - } - - @Test - public void shouldRunEnterprise() { - assumeThat(Neo4jContainerTest.class.getResource(ACCEPTANCE_FILE_LOCATION)).isNotNull(); - - try ( - Neo4jContainer neo4jContainer = new Neo4jContainer<>(Neo4jTestImages.NEO4J_TEST_IMAGE) - .withEnterpriseEdition() - .withAdminPassword("Picard123") - ) { - neo4jContainer.start(); - try ( - Driver driver = getDriver(neo4jContainer); - 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 - public void shouldAddConfigToEnvironment() { - - Neo4jContainer neo4jContainer = new Neo4jContainer<>(Neo4jTestImages.NEO4J_TEST_IMAGE) - .withNeo4jConfig("dbms.security.procedures.unrestricted", "apoc.*,algo.*") - .withNeo4jConfig("dbms.tx_log.rotation.size", "42M"); - - assertThat(neo4jContainer.getEnvMap()) - .containsEntry("NEO4J_dbms_security_procedures_unrestricted", "apoc.*,algo.*"); - assertThat(neo4jContainer.getEnvMap()) - .containsEntry("NEO4J_dbms_tx__log_rotation_size", "42M"); - } - - private static Driver getDriver(Neo4jContainer container) { - - AuthToken authToken = AuthTokens.none(); - if (container.getAdminPassword() != null) { - authToken = AuthTokens.basic("neo4j", container.getAdminPassword()); - } - return GraphDatabase.driver(container.getBoltUrl(), authToken); - } -} diff --git a/modules/neo4j/src/test/java/org/testcontainers/containers/Neo4jTestImages.java b/modules/neo4j/src/test/java/org/testcontainers/containers/Neo4jTestImages.java deleted file mode 100644 index 2cab24baf2b..00000000000 --- a/modules/neo4j/src/test/java/org/testcontainers/containers/Neo4jTestImages.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.testcontainers.containers; - -import org.testcontainers.utility.DockerImageName; - -public interface Neo4jTestImages { - DockerImageName NEO4J_TEST_IMAGE = DockerImageName.parse("neo4j:3.5.0"); -} 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 index 9b92a79c737..e1f78f2d982 100644 --- a/modules/neo4j/src/test/resources/example-container-license-acceptance.txt +++ b/modules/neo4j/src/test/resources/example-container-license-acceptance.txt @@ -1 +1 @@ -neo4j:3.5.0-enterprise +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 index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/neo4j/src/test/resources/logback-test.xml +++ b/modules/neo4j/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + diff --git a/modules/nginx/build.gradle b/modules/nginx/build.gradle index 6c4b97876c4..60d24d863d8 100644 --- a/modules/nginx/build.gradle +++ b/modules/nginx/build.gradle @@ -1,6 +1,6 @@ description = "Testcontainers :: Nginx" dependencies { - compile project(':testcontainers') - compileOnly 'org.jetbrains:annotations:20.1.0' + api project(':testcontainers') + compileOnly 'org.jetbrains:annotations:26.1.0' } 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 296d82b7997..c66149d2417 100644 --- a/modules/nginx/src/main/java/org/testcontainers/containers/NginxContainer.java +++ b/modules/nginx/src/main/java/org/testcontainers/containers/NginxContainer.java @@ -6,20 +6,24 @@ import java.net.MalformedURLException; import java.net.URL; -import java.util.Collections; 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 use {@link #NginxContainer(DockerImageName)} instead */ @Deprecated public NginxContainer() { @@ -32,27 +36,33 @@ public NginxContainer(String dockerImageName) { 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 Collections.singleton(getMappedPort(80)); + return super.getLivenessCheckPorts(); } public URL getBaseUrl(String scheme, int port) throws MalformedURLException { 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 e322804bf75..00000000000 --- a/modules/nginx/src/test/java/org/testcontainers/junit/SimpleNginxTest.java +++ /dev/null @@ -1,68 +0,0 @@ -package org.testcontainers.junit; - -import lombok.Cleanup; -import org.junit.BeforeClass; -import org.junit.Rule; -import org.junit.Test; -import org.testcontainers.containers.NginxContainer; -import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; -import org.testcontainers.utility.DockerImageName; - -import java.io.*; -import java.net.URL; -import java.net.URLConnection; - -import static org.hamcrest.CoreMatchers.containsString; -import static org.rnorth.visibleassertions.VisibleAssertions.*; - -/** - * @author richardnorth - */ -public class SimpleNginxTest { - - private static final DockerImageName NGINX_IMAGE = DockerImageName.parse("nginx:1.9.4"); - - private static String tmpDirectory = System.getProperty("user.home") + "/.tmp-test-container"; - - // creatingContainer { - @Rule - public NginxContainer nginx = new NginxContainer<>(NGINX_IMAGE) - .withCustomContent(tmpDirectory) - .waitingFor(new HttpWaitStrategy()); - // } - - @SuppressWarnings({"Duplicates", "ResultOfMethodCallIgnored"}) - @BeforeClass - public 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 - public void testSimple() throws Exception { - // getFromNginxServer { - URL baseUrl = nginx.getBaseUrl("http", 80); - - assertThat("An HTTP GET from the Nginx server returns the index.html from the custom content directory", - responseFromNginx(baseUrl), - containsString("Hello World!") - ); - // } - } - - 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/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 535e406fc13..83ef7a1a3ef 100644 --- a/modules/nginx/src/test/resources/logback-test.xml +++ b/modules/nginx/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + 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-free/src/test/java/org/testcontainers/oracle/jdbc/OracleJDBCDriverTest.java b/modules/oracle-free/src/test/java/org/testcontainers/oracle/jdbc/OracleJDBCDriverTest.java new file mode 100644 index 00000000000..a4982e966c1 --- /dev/null +++ b/modules/oracle-free/src/test/java/org/testcontainers/oracle/jdbc/OracleJDBCDriverTest.java @@ -0,0 +1,48 @@ +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.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-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 index 026521e9a0c..1fb43310ae0 100644 --- a/modules/oracle-xe/build.gradle +++ b/modules/oracle-xe/build.gradle @@ -1,8 +1,16 @@ description = "Testcontainers :: JDBC :: Oracle XE" dependencies { - compile project(':jdbc') + api project(':testcontainers-jdbc') - testCompile project(':jdbc-test') - testCompile 'com.oracle.ojdbc:ojdbc8:19.3.0.0' + 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/src/main/java/org/testcontainers/containers/OracleContainer.java b/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleContainer.java index 0a66ad6fd27..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,43 +1,74 @@ 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 org.testcontainers.utility.TestcontainersConfiguration; +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 static final String NAME = "oracle"; - 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; - private String username = "system"; - private String password = "oracle"; + // Container defaults + static final String DEFAULT_DATABASE_NAME = "xepdb1"; - private static String resolveImageName() { - String image = TestcontainersConfiguration.getInstance().getOracleImage(); + static final String DEFAULT_SID = "xe"; - if (image == null) { - throw new IllegalStateException("An image to use for Oracle containers must be configured. " + - "To do this, please place a file on the classpath named `testcontainers.properties`, " + - "containing `oracle.container.image=IMAGE`, where IMAGE is a suitable image name and tag."); - } - return image; - } + 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 use {@link #OracleContainer(DockerImageName)} instead */ @Deprecated public OracleContainer() { - this(resolveImageName()); + this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); } public OracleContainer(String dockerImageName) { @@ -46,6 +77,7 @@ public OracleContainer(String dockerImageName) { public OracleContainer(final DockerImageName dockerImageName) { super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); preconfigure(); } @@ -55,29 +87,48 @@ public OracleContainer(Future dockerImageName) { } private void preconfigure() { - withStartupTimeoutSeconds(DEFAULT_STARTUP_TIMEOUT_SECONDS); + 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 Integer getLivenessCheckPort() { - return getMappedPort(ORACLE_PORT); + 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() + "@" + getHost() + ":" + getOraclePort() + ":" + getSid(); + return isUsingSid() + ? "jdbc:oracle:thin:" + "@" + getHost() + ":" + getOraclePort() + ":" + getSid() + : "jdbc:oracle:thin:" + "@" + getHost() + ":" + getOraclePort() + "/" + getDatabaseName(); } @Override public String getUsername() { - return username; + // An application user is tied to the database, and therefore not authenticated to connect to SID. + return isUsingSid() ? DEFAULT_SYSTEM_USER : username; } @Override @@ -85,26 +136,63 @@ 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 OracleDb does not support this"); + throw new UnsupportedOperationException("The Oracle Database driver does not support this"); } @SuppressWarnings("SameReturnValue") public String getSid() { - return "xe"; + return DEFAULT_SID; } public Integer getOraclePort() { @@ -120,4 +208,17 @@ public Integer getWebPort() { 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-xe/src/main/java/org/testcontainers/containers/OracleContainerProvider.java b/modules/oracle-xe/src/main/java/org/testcontainers/containers/OracleContainerProvider.java index 853ffdaf17a..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,9 +1,12 @@ 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); @@ -11,15 +14,14 @@ public boolean supports(String databaseType) { @Override public JdbcDatabaseContainer newInstance() { - return new OracleContainer(); + return newInstance(OracleContainer.DEFAULT_TAG); } @Override public JdbcDatabaseContainer newInstance(String tag) { if (tag != null) { - throw new UnsupportedOperationException("Oracle database tag should be set in the configured image name"); + return new OracleContainer(DockerImageName.parse(OracleContainer.IMAGE).withTag(tag)); } - - return new OracleContainer(); + 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 index fbe5ce95a05..4b672827fe6 100644 --- 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 @@ -4,37 +4,35 @@ import com.zaxxer.hikari.HikariDataSource; import org.apache.commons.dbutils.QueryRunner; import org.apache.commons.dbutils.ResultSetHandler; -import org.junit.Ignore; -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 - */ -@Ignore -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-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/oracle/SimpleOracleTest.java b/modules/oracle-xe/src/test/java/org/testcontainers/junit/oracle/SimpleOracleTest.java index 2415fed11f4..17ee85944a7 100644 --- 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 @@ -1,26 +1,146 @@ package org.testcontainers.junit.oracle; -import org.junit.Ignore; -import org.junit.Test; +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.rnorth.visibleassertions.VisibleAssertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; -@Ignore -public class SimpleOracleTest extends AbstractContainerDatabaseTest { +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 - public void testSimple() throws SQLException { - try (OracleContainer oracle = new OracleContainer()) { - ResultSet resultSet = performQuery(oracle, "SELECT 1 FROM dual"); + 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 + } - int resultSetInt = resultSet.getInt(1); + try { + oracle.withUsername(""); + fail("Should not have been able to set username to nothing."); + } catch (IllegalArgumentException e) { + //expected + } - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); + 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 535e406fc13..83ef7a1a3ef 100644 --- a/modules/oracle-xe/src/test/resources/logback-test.xml +++ b/modules/oracle-xe/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + diff --git a/modules/orientdb/build.gradle b/modules/orientdb/build.gradle index ffa1350172a..6d691b407ce 100644 --- a/modules/orientdb/build.gradle +++ b/modules/orientdb/build.gradle @@ -1,11 +1,10 @@ -description = "TestContainers :: Orientdb" +description = "Testcontainers :: Orientdb" dependencies { - compile project(":testcontainers") + api project(":testcontainers") - compile "com.orientechnologies:orientdb-client:3.0.24" + api "com.orientechnologies:orientdb-client:3.2.53" - testCompile 'org.assertj:assertj-core:3.12.0' - testCompile 'org.apache.tinkerpop:gremlin-driver:3.3.4' - testCompile "com.orientechnologies:orientdb-gremlin:3.0.18" + 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 index f6ce060b6ec..e21472dd16a 100644 --- a/modules/orientdb/src/main/java/org/testcontainers/containers/OrientDBContainer.java +++ b/modules/orientdb/src/main/java/org/testcontainers/containers/OrientDBContainer.java @@ -9,47 +9,62 @@ import org.apache.commons.io.IOUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; import org.testcontainers.containers.wait.strategy.Wait; -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 java.io.IOException; import java.net.URL; import java.nio.charset.StandardCharsets; -import java.time.Duration; +import java.util.Arrays; import java.util.Optional; -import static java.net.HttpURLConnection.HTTP_OK; - /** - * @author robfrank + * 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 use {@link #OrientDBContainer(DockerImageName)} instead */ @Deprecated public OrientDBContainer() { @@ -62,20 +77,12 @@ public OrientDBContainer(@NonNull String dockerImageName) { public OrientDBContainer(final DockerImageName dockerImageName) { super(dockerImageName); - dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); serverPassword = DEFAULT_SERVER_PASSWORD; databaseName = DEFAULT_DATABASE_NAME; - WaitStrategy waitForHttp = new HttpWaitStrategy() - .forPort(DEFAULT_HTTP_PORT) - .forStatusCodeMatching(response -> response == HTTP_OK); - - waitStrategy = new WaitAllStrategy() - .withStrategy(Wait.forListeningPort()) - .withStrategy(waitForHttp) - .withStartupTimeout(Duration.ofMinutes(2)); + waitStrategy = Wait.forLogMessage(".*OrientDB Studio available.*", 1); addExposedPorts(DEFAULT_BINARY_PORT, DEFAULT_HTTP_PORT); } @@ -113,6 +120,7 @@ protected void containerIsStarted(InspectContainerResponse containerInfo) { orientDB = new OrientDB(getServerUrl(), "root", serverPassword, OrientDBConfig.defaultConfig()); } + @Deprecated public OrientDB getOrientDB() { return orientDB; } @@ -125,13 +133,33 @@ 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) { - orientDB.createIfNotExists(databaseName, ODatabaseType.PLOCAL); - + 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); @@ -140,13 +168,16 @@ public synchronized ODatabaseSession getSession(String username, String password 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."); + throw new RuntimeException( + "Could not load classpath init script: " + scriptPath + ". Resource not found." + ); } String script = IOUtils.toString(resource, StandardCharsets.UTF_8); @@ -160,5 +191,4 @@ private void loadScript(String path, ODatabaseSession session) { 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/containers/OrientDBContainerTest.java b/modules/orientdb/src/test/java/org/testcontainers/containers/OrientDBContainerTest.java deleted file mode 100644 index 60d7b172436..00000000000 --- a/modules/orientdb/src/test/java/org/testcontainers/containers/OrientDBContainerTest.java +++ /dev/null @@ -1,76 +0,0 @@ -package org.testcontainers.containers; - -import com.orientechnologies.orient.core.db.ODatabaseSession; -import org.junit.Test; -import org.testcontainers.utility.DockerImageName; - -import static org.assertj.core.api.Assertions.*; - -/** - * @author robfrank - */ -public class OrientDBContainerTest { - - private static final DockerImageName ORIENTDB_IMAGE = DockerImageName.parse("orientdb:3.0.24-tp3"); - - @Test - public void shouldReturnTheSameSession() { - try (OrientDBContainer container = new OrientDBContainer(ORIENTDB_IMAGE)) { - container.start(); - - final ODatabaseSession session = container.getSession(); - final ODatabaseSession session2 = container.getSession(); - - assertThat(session).isSameAs(session2); - } - } - - @Test - public void shouldInitializeWithCommands() { - try (OrientDBContainer container = new OrientDBContainer(ORIENTDB_IMAGE)) { - container.start(); - - final ODatabaseSession session = container.getSession(); - - 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 - public void shouldQueryWithGremlin() { - - try (OrientDBContainer container = new OrientDBContainer(ORIENTDB_IMAGE)) { - container.start(); - - final ODatabaseSession session = container.getSession("admin", "admin"); - - 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 - public void shouldInitializeDatabaseFromScript() { - try (OrientDBContainer container = new OrientDBContainer(ORIENTDB_IMAGE) - .withScriptPath("initscript.osql") - .withDatabaseName("persons")) { - - container.start(); - - assertThat(container.getDbUrl()) - .isEqualTo("remote:" + container.getHost() + ":" + container.getMappedPort(2424) + "/persons"); - - final ODatabaseSession session = container.getSession(); - - assertThat(session.query("SELECT FROM Person").stream()).hasSize(4); - } - } -} 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/logback-test.xml b/modules/orientdb/src/test/resources/logback-test.xml index 7bd6a94d827..d91138d8b74 100644 --- a/modules/orientdb/src/test/resources/logback-test.xml +++ b/modules/orientdb/src/test/resources/logback-test.xml @@ -12,7 +12,7 @@ - + - \ No newline at end of file + 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 index b16ceaea2a8..db7382f34d3 100644 --- a/modules/postgresql/build.gradle +++ b/modules/postgresql/build.gradle @@ -1,20 +1,16 @@ description = "Testcontainers :: JDBC :: PostgreSQL" dependencies { - annotationProcessor 'com.google.auto.service:auto-service:1.0-rc7' - compileOnly 'com.google.auto.service:auto-service:1.0-rc6' + api project(':testcontainers-jdbc') - compile project(':jdbc') + compileOnly project(':testcontainers-r2dbc') + compileOnly 'io.r2dbc:r2dbc-postgresql:0.8.13.RELEASE' - compileOnly project(':r2dbc') - compileOnly 'io.r2dbc:r2dbc-postgresql:0.8.5.RELEASE' + testImplementation project(':testcontainers-jdbc-test') + testRuntimeOnly 'org.postgresql:postgresql:42.7.12' - testCompile project(':jdbc-test') - testCompile project(':test-support') - testCompile 'org.postgresql:postgresql:42.2.18' + testImplementation testFixtures(project(':testcontainers-r2dbc')) + testRuntimeOnly 'io.r2dbc:r2dbc-postgresql:0.8.13.RELEASE' - testCompile testFixtures(project(':r2dbc')) - testCompile 'io.r2dbc:r2dbc-postgresql:0.8.5.RELEASE' - - compileOnly 'org.jetbrains:annotations:20.1.0' + compileOnly 'org.jetbrains:annotations:26.1.0' } 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 index e41a349365c..0c30ebbb246 100644 --- a/modules/postgresql/src/main/java/org/testcontainers/containers/PostgisContainerProvider.java +++ b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgisContainerProvider.java @@ -9,11 +9,16 @@ 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 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"; + public static final String PASSWORD_PARAM = "password"; @Override public boolean supports(String databaseType) { 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 6c16bb8fe61..4824654957f 100644 --- a/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLContainer.java +++ b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLContainer.java @@ -5,21 +5,31 @@ import org.testcontainers.utility.DockerImageName; import java.time.Duration; +import java.time.temporal.ChronoUnit; import java.util.Set; -import static java.time.temporal.ChronoUnit.SECONDS; -import static java.util.Collections.singleton; - /** - * @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"; @@ -27,15 +37,15 @@ public class PostgreSQLContainer> extends 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"; - private static final String QUERY_PARAM_SEPARATOR = "&"; - /** - * @deprecated use {@link PostgreSQLContainer(DockerImageName)} instead + * @deprecated use {@link #PostgreSQLContainer(DockerImageName)} or {@link #PostgreSQLContainer(String)} instead */ @Deprecated public PostgreSQLContainer() { @@ -48,22 +58,27 @@ public PostgreSQLContainer(final String dockerImageName) { public PostgreSQLContainer(final DockerImageName dockerImageName) { super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, PGVECTOR_IMAGE_NAME); - dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); - - this.waitStrategy = new LogMessageWaitStrategy() + this.waitStrategy = + new LogMessageWaitStrategy() .withRegEx(".*database system is ready to accept connections.*\\s") .withTimes(2) - .withStartupTimeout(Duration.of(60, SECONDS)); + .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 + @Deprecated protected Set getLivenessCheckPorts() { - return singleton(getMappedPort(POSTGRESQL_PORT)); + return super.getLivenessCheckPorts(); } @Override @@ -83,8 +98,15 @@ public String getDriverClassName() { @Override public String getJdbcUrl() { String additionalUrlParams = constructUrlParameters("?", "&"); - return "jdbc:postgresql://" + getContainerIpAddress() + ":" + getMappedPort(POSTGRESQL_PORT) - + "/" + databaseName + additionalUrlParams; + return ( + "jdbc:postgresql://" + + getHost() + + ":" + + getMappedPort(POSTGRESQL_PORT) + + "/" + + databaseName + + additionalUrlParams + ); } @Override 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 5e7580c75eb..de98885510c 100644 --- a/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLContainerProvider.java +++ b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLContainerProvider.java @@ -9,6 +9,7 @@ public class PostgreSQLContainerProvider extends JdbcDatabaseContainerProvider { public static final String USER_PARAM = "user"; + public static final String PASSWORD_PARAM = "password"; @Override @@ -30,5 +31,4 @@ public JdbcDatabaseContainer newInstance(String tag) { 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 index 88b5497f5d3..d2b933694c7 100644 --- a/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainer.java +++ b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainer.java @@ -13,7 +13,8 @@ public final class PostgreSQLR2DBCDatabaseContainer implements R2DBCDatabaseCont private final PostgreSQLContainer container; public static ConnectionFactoryOptions getOptions(PostgreSQLContainer container) { - ConnectionFactoryOptions options = ConnectionFactoryOptions.builder() + ConnectionFactoryOptions options = ConnectionFactoryOptions + .builder() .option(ConnectionFactoryOptions.DRIVER, PostgreSQLR2DBCDatabaseContainerProvider.DRIVER) .build(); @@ -22,7 +23,8 @@ public static ConnectionFactoryOptions getOptions(PostgreSQLContainer contain @Override public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { - return options.mutate() + return options + .mutate() .option(ConnectionFactoryOptions.HOST, container.getHost()) .option(ConnectionFactoryOptions.PORT, container.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT)) .option(ConnectionFactoryOptions.DATABASE, container.getDatabaseName()) diff --git a/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerProvider.java b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerProvider.java index 971b4cd3758..f1bdf67ee5c 100644 --- a/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerProvider.java +++ b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerProvider.java @@ -1,6 +1,5 @@ package org.testcontainers.containers; -import com.google.auto.service.AutoService; import io.r2dbc.postgresql.PostgresqlConnectionFactoryProvider; import io.r2dbc.spi.ConnectionFactoryMetadata; import io.r2dbc.spi.ConnectionFactoryOptions; @@ -9,7 +8,6 @@ import javax.annotation.Nullable; -@AutoService(R2DBCDatabaseContainerProvider.class) public final class PostgreSQLR2DBCDatabaseContainerProvider implements R2DBCDatabaseContainerProvider { static final String DRIVER = PostgresqlConnectionFactoryProvider.POSTGRESQL_DRIVER; @@ -23,7 +21,7 @@ public boolean supports(ConnectionFactoryOptions options) { public R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options) { String image = PostgreSQLContainer.IMAGE + ":" + options.getRequiredValue(IMAGE_TAG_OPTION); PostgreSQLContainer container = new PostgreSQLContainer<>(image) - .withDatabaseName(options.getRequiredValue(ConnectionFactoryOptions.DATABASE)); + .withDatabaseName((String) options.getRequiredValue(ConnectionFactoryOptions.DATABASE)); if (Boolean.TRUE.equals(options.getValue(REUSABLE_OPTION))) { container.withReuse(true); 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 79b2f29340a..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,2 +1,4 @@ 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/containers/PostgreSQLConnectionURLTest.java b/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLConnectionURLTest.java index c18f19a109e..3ed0b7030b1 100644 --- a/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLConnectionURLTest.java +++ b/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLConnectionURLTest.java @@ -1,52 +1,60 @@ package org.testcontainers.containers; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.testcontainers.PostgreSQLTestImages; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertFalse; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThrows; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; -public class PostgreSQLConnectionURLTest { +class PostgreSQLConnectionURLTest { @Test - public void shouldCorrectlyAppendQueryString() { + void shouldCorrectlyAppendQueryString() { PostgreSQLContainer postgres = new FixedJdbcUrlPostgreSQLContainer(); String connectionUrl = postgres.constructUrlForConnection("?stringtype=unspecified&stringtype=unspecified"); String queryString = connectionUrl.substring(connectionUrl.indexOf('?')); - assertTrue("Query String contains expected params", queryString.contains("?stringtype=unspecified&stringtype=unspecified")); - assertEquals("Query String starts with '?'", 0, queryString.indexOf('?')); - assertFalse("Query String does not contain extra '?'", queryString.substring(1).contains("?")); + 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 - public void shouldCorrectlyAppendQueryStringWhenNoBaseParams() { + void shouldCorrectlyAppendQueryStringWhenNoBaseParams() { PostgreSQLContainer postgres = new NoParamsUrlPostgreSQLContainer(); String connectionUrl = postgres.constructUrlForConnection("?stringtype=unspecified&stringtype=unspecified"); String queryString = connectionUrl.substring(connectionUrl.indexOf('?')); - assertTrue("Query String contains expected params", queryString.contains("?stringtype=unspecified&stringtype=unspecified")); - assertEquals("Query String starts with '?'", 0, queryString.indexOf('?')); - assertFalse("Query String does not contain extra '?'", queryString.substring(1).contains("?")); + 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 - public void shouldReturnOriginalURLWhenEmptyQueryString() { + void shouldReturnOriginalURLWhenEmptyQueryString() { PostgreSQLContainer postgres = new FixedJdbcUrlPostgreSQLContainer(); String connectionUrl = postgres.constructUrlForConnection(""); - assertTrue("Query String remains unchanged", postgres.getJdbcUrl().equals(connectionUrl)); + assertThat(postgres.getJdbcUrl()).as("Query String remains unchanged").isEqualTo(connectionUrl); } @Test - public void shouldRejectInvalidQueryString() { - assertThrows("Fails when invalid query string provided", IllegalArgumentException.class, - () -> new NoParamsUrlPostgreSQLContainer().constructUrlForConnection("stringtype=unspecified")); + 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); } @@ -63,6 +71,7 @@ public Integer getMappedPort(int originalPort) { } static class NoParamsUrlPostgreSQLContainer extends PostgreSQLContainer { + public NoParamsUrlPostgreSQLContainer() { super(PostgreSQLTestImages.POSTGRES_TEST_IMAGE); } diff --git a/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerTest.java b/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerTest.java index 46e439aaf66..fb503bfd659 100644 --- a/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerTest.java +++ b/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerTest.java @@ -13,11 +13,13 @@ protected PostgreSQLContainer createContainer() { @Override protected ConnectionFactoryOptions getOptions(PostgreSQLContainer container) { + // spotless:off // get_options { ConnectionFactoryOptions options = PostgreSQLR2DBCDatabaseContainer.getOptions( container ); // } + // spotless:on return options; } 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 index 9e3e0b03413..ab4d6f03d2e 100644 --- a/modules/postgresql/src/test/java/org/testcontainers/jdbc/DatabaseDriverShutdownTest.java +++ b/modules/postgresql/src/test/java/org/testcontainers/jdbc/DatabaseDriverShutdownTest.java @@ -1,16 +1,14 @@ package org.testcontainers.jdbc; -import org.junit.AfterClass; -import org.junit.Test; +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.rnorth.visibleassertions.VisibleAssertions.assertNotNull; -import static org.rnorth.visibleassertions.VisibleAssertions.assertNull; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; +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}. @@ -18,36 +16,37 @@ * 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. */ -public class DatabaseDriverShutdownTest { - @AfterClass +class DatabaseDriverShutdownTest { + + @BeforeAll public static void testCleanup() { ContainerDatabaseDriver.killContainers(); } @Test - public void shouldStopContainerWhenAllConnectionsClosed() throws SQLException { + void shouldStopContainerWhenAllConnectionsClosed() throws SQLException { final String jdbcUrl = "jdbc:tc:postgresql:9.6.8://hostname/databasename"; getConnectionAndClose(jdbcUrl); JdbcDatabaseContainer container = ContainerDatabaseDriver.getContainer(jdbcUrl); - assertNull("Database container instance is null as expected", container); + assertThat(container).as("Database container instance is null as expected").isNull(); } @Test - public void shouldNotStopDaemonContainerWhenAllConnectionsClosed() throws SQLException { + 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); - assertNotNull("Database container instance is not null as expected", container); - assertTrue("Database container is running as expected", container.isRunning()); + 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)) { - assertNotNull("Obtained connection as expected", connection); + 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 index a2b1fd5da8b..12a6f2216cf 100644 --- a/modules/postgresql/src/test/java/org/testcontainers/jdbc/DatabaseDriverTmpfsTest.java +++ b/modules/postgresql/src/test/java/org/testcontainers/jdbc/DatabaseDriverTmpfsTest.java @@ -1,14 +1,13 @@ package org.testcontainers.jdbc; -import org.junit.Test; +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.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertNotEquals; +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}. @@ -16,22 +15,23 @@ * 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. */ -public class DatabaseDriverTmpfsTest { +class DatabaseDriverTmpfsTest { @Test - public void testDatabaseHasTmpFsViaConnectionString() throws Exception { + 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); - assertNotEquals("tmpfs inside container doesn't have file that doesn't exist", 0, execResult.getExitCode()); + 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); - assertEquals("tmpfs inside container has file that does exist", 0, execResult.getExitCode()); + 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 index b1d01441016..d6a0047a23c 100644 --- a/modules/postgresql/src/test/java/org/testcontainers/jdbc/postgis/PostgisJDBCDriverTest.java +++ b/modules/postgresql/src/test/java/org/testcontainers/jdbc/postgis/PostgisJDBCDriverTest.java @@ -1,22 +1,24 @@ package org.testcontainers.jdbc.postgis; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; import org.testcontainers.jdbc.AbstractJDBCDriverTest; +import java.util.Arrays; import java.util.EnumSet; -import static java.util.Arrays.asList; +class PostgisJDBCDriverTest extends AbstractJDBCDriverTest { -@RunWith(Parameterized.class) -public class PostgisJDBCDriverTest extends AbstractJDBCDriverTest { - - @Parameterized.Parameters(name = "{index} - {0}") public static Iterable data() { - return 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)}, - }); + 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 index c63eb9ffb9d..b47af39f54b 100644 --- a/modules/postgresql/src/test/java/org/testcontainers/jdbc/postgresql/PostgreSQLJDBCDriverTest.java +++ b/modules/postgresql/src/test/java/org/testcontainers/jdbc/postgresql/PostgreSQLJDBCDriverTest.java @@ -1,21 +1,20 @@ package org.testcontainers.jdbc.postgresql; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; import org.testcontainers.jdbc.AbstractJDBCDriverTest; +import java.util.Arrays; import java.util.EnumSet; -import static java.util.Arrays.asList; - -@RunWith(Parameterized.class) public class PostgreSQLJDBCDriverTest extends AbstractJDBCDriverTest { - @Parameterized.Parameters(name = "{index} - {0}") public static Iterable data() { - return asList( - new Object[][]{ - {"jdbc:tc:postgresql:9.6.8://hostname/databasename?user=someuser&password=somepwd", EnumSet.of(Options.JDBCParams)}, - }); + 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/junit/postgresql/CustomizablePostgreSQLTest.java b/modules/postgresql/src/test/java/org/testcontainers/junit/postgresql/CustomizablePostgreSQLTest.java deleted file mode 100644 index 8fd523434dd..00000000000 --- a/modules/postgresql/src/test/java/org/testcontainers/junit/postgresql/CustomizablePostgreSQLTest.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.testcontainers.junit.postgresql; - -import org.junit.Test; -import org.testcontainers.PostgreSQLTestImages; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.db.AbstractContainerDatabaseTest; - -import java.sql.ResultSet; -import java.sql.SQLException; - -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; - -/** - * @author richardnorth - */ -public class CustomizablePostgreSQLTest extends AbstractContainerDatabaseTest { - private static final String DB_NAME = "foo"; - private static final String USER = "bar"; - private static final String PWD = "baz"; - - @Test - public void testSimple() throws SQLException { - try (PostgreSQLContainer postgres = new PostgreSQLContainer<>(PostgreSQLTestImages.POSTGRES_TEST_IMAGE) - .withDatabaseName(DB_NAME) - .withUsername(USER) - .withPassword(PWD)) { - - postgres.start(); - - ResultSet resultSet = performQuery(postgres, "SELECT 1"); - - int resultSetInt = resultSet.getInt(1); - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } - } -} diff --git a/modules/postgresql/src/test/java/org/testcontainers/junit/postgresql/SimplePostgreSQLTest.java b/modules/postgresql/src/test/java/org/testcontainers/junit/postgresql/SimplePostgreSQLTest.java deleted file mode 100644 index 95e1fdf10fd..00000000000 --- a/modules/postgresql/src/test/java/org/testcontainers/junit/postgresql/SimplePostgreSQLTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package org.testcontainers.junit.postgresql; - -import org.junit.Test; -import org.testcontainers.containers.PostgreSQLContainer; -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.hamcrest.CoreMatchers.containsString; -import static org.junit.Assert.assertThat; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertNotEquals; -import static org.testcontainers.PostgreSQLTestImages.POSTGRES_TEST_IMAGE; - -public class SimplePostgreSQLTest 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 - public void testSimple() throws SQLException { - try (PostgreSQLContainer postgres = new PostgreSQLContainer<>(POSTGRES_TEST_IMAGE)) { - postgres.start(); - - ResultSet resultSet = performQuery(postgres, "SELECT 1"); - int resultSetInt = resultSet.getInt(1); - assertEquals("A basic SELECT query succeeds", 1, resultSetInt); - } - } - - @Test - public void testCommandOverride() throws SQLException { - try (PostgreSQLContainer postgres = new PostgreSQLContainer<>(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); - assertEquals("max_connections should be overriden", "42", result); - } - } - - @Test - public void testUnsetCommand() throws SQLException { - try (PostgreSQLContainer postgres = new PostgreSQLContainer<>(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); - assertNotEquals("max_connections should not be overriden", "42", result); - } - } - - @Test - public void testExplicitInitScript() throws SQLException { - try (PostgreSQLContainer postgres = new PostgreSQLContainer<>(POSTGRES_TEST_IMAGE).withInitScript("somepath/init_postgresql.sql")) { - postgres.start(); - - ResultSet resultSet = performQuery(postgres, "SELECT foo FROM bar"); - - String firstColumnValue = resultSet.getString(1); - assertEquals("Value from init script should equal real value", "hello world", firstColumnValue); - } - } - - @Test - public void testWithAdditionalUrlParamInJdbcUrl() { - try (PostgreSQLContainer postgres = new PostgreSQLContainer<>(POSTGRES_TEST_IMAGE) - .withUrlParam("charSet", "UNICODE")) { - - postgres.start(); - String jdbcUrl = postgres.getJdbcUrl(); - assertThat(jdbcUrl, containsString("?")); - assertThat(jdbcUrl, containsString("&")); - assertThat(jdbcUrl, containsString("charSet=UNICODE")); - } - } -} 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 index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/postgresql/src/test/resources/logback-test.xml +++ b/modules/postgresql/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + 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 index 4130fb74891..1d2933dec53 100644 --- a/modules/presto/build.gradle +++ b/modules/presto/build.gradle @@ -1,9 +1,9 @@ description = "Testcontainers :: JDBC :: Presto" dependencies { - compile project(':jdbc') + api project(':testcontainers-jdbc') - testCompile project(':jdbc-test') - testCompile 'io.prestosql:presto-jdbc:331' - compileOnly 'org.jetbrains:annotations:20.0.0' + 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 index 1d9e02114ac..ac01eff1882 100644 --- a/modules/presto/src/main/java/org/testcontainers/containers/PrestoContainer.java +++ b/modules/presto/src/main/java/org/testcontainers/containers/PrestoContainer.java @@ -1,32 +1,38 @@ package org.testcontainers.containers; +import com.google.common.base.Strings; import org.jetbrains.annotations.NotNull; -import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +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.util.HashSet; +import java.time.temporal.ChronoUnit; import java.util.Set; -import static com.google.common.base.Strings.nullToEmpty; -import static java.lang.String.format; -import static java.time.temporal.ChronoUnit.SECONDS; - +/** + * @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 use {@link #PrestoContainer(DockerImageName)} instead */ @Deprecated public PrestoContainer() { @@ -39,20 +45,26 @@ public PrestoContainer(final String dockerImageName) { public PrestoContainer(final DockerImageName dockerImageName) { super(dockerImageName); - dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); - this.waitStrategy = new LogMessageWaitStrategy() - .withRegEx(".*======== SERVER STARTED ========.*") - .withStartupTimeout(Duration.of(60, SECONDS)); + 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 new HashSet<>(getMappedPort(PRESTO_PORT)); + return super.getLivenessCheckPorts(); } @Override @@ -62,7 +74,12 @@ public String getDriverClassName() { @Override public String getJdbcUrl() { - return format("jdbc:presto://%s:%s/%s", getHost(), getMappedPort(PRESTO_PORT), nullToEmpty(catalog)); + return String.format( + "jdbc:presto://%s:%s/%s", + getHost(), + getMappedPort(PRESTO_PORT), + Strings.nullToEmpty(catalog) + ); } @Override diff --git a/modules/presto/src/main/java/org/testcontainers/containers/PrestoContainerProvider.java b/modules/presto/src/main/java/org/testcontainers/containers/PrestoContainerProvider.java index 8408aab4517..f37efb9438e 100644 --- a/modules/presto/src/main/java/org/testcontainers/containers/PrestoContainerProvider.java +++ b/modules/presto/src/main/java/org/testcontainers/containers/PrestoContainerProvider.java @@ -9,6 +9,7 @@ public class PrestoContainerProvider extends JdbcDatabaseContainerProvider { public static final String USER_PARAM = "user"; + public static final String PASSWORD_PARAM = "password"; @Override @@ -30,5 +31,4 @@ public JdbcDatabaseContainer newInstance(String tag) { public JdbcDatabaseContainer newInstance(ConnectionUrl connectionUrl) { return newInstanceFromConnectionUrl(connectionUrl, USER_PARAM, PASSWORD_PARAM); } - } diff --git a/modules/presto/src/test/java/org/testcontainers/PrestoTestImages.java b/modules/presto/src/test/java/org/testcontainers/PrestoTestImages.java index 174cb7b4d4a..498e09f0be4 100644 --- a/modules/presto/src/test/java/org/testcontainers/PrestoTestImages.java +++ b/modules/presto/src/test/java/org/testcontainers/PrestoTestImages.java @@ -4,5 +4,6 @@ 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 index d5a4d9587a6..9a46309f9e9 100644 --- a/modules/presto/src/test/java/org/testcontainers/containers/PrestoContainerTest.java +++ b/modules/presto/src/test/java/org/testcontainers/containers/PrestoContainerTest.java @@ -1,9 +1,7 @@ package org.testcontainers.containers; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.testcontainers.PrestoTestImages; -import org.testcontainers.utility.DockerImageName; import java.sql.Connection; import java.sql.DriverManager; @@ -14,110 +12,145 @@ import java.util.Arrays; import java.util.List; -import static java.lang.Integer.parseInt; -import static java.lang.String.format; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; -/** - * @author findepi - */ -public class PrestoContainerTest { +class PrestoContainerTest { @Test - public void testSimple() throws Exception { + 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")) { - assertTrue("No result", resultSet.next()); - assertEquals("Presto version", PrestoContainer.DEFAULT_TAG, resultSet.getString("node_version")); + 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 - public void testSpecificVersion() throws Exception { - try (PrestoContainer prestoSql = new PrestoContainer<>(PrestoTestImages.PRESTO_PREVIOUS_VERSION_TEST_IMAGE)) { + 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")) { - assertTrue("No result", resultSet.next()); - assertEquals("Presto version", PrestoTestImages.PRESTO_PREVIOUS_VERSION_TEST_IMAGE.getVersionPart(), resultSet.getString("node_version")); + 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 - public void testQueryMemoryAndTpch() throws SQLException { + void testQueryMemoryAndTpch() throws SQLException { try (PrestoContainer prestoSql = new PrestoContainer<>(PrestoTestImages.PRESTO_TEST_IMAGE)) { prestoSql.start(); - try (Connection connection = prestoSql.createConnection(); - Statement statement = connection.createStatement()) { + 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"); + 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 ")) { + 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); + assertThat(actualElements).isEqualTo(Arrays.asList(2, 4, 42, 42, 42)); } } } } @Test - public void testInitScript() throws Exception { + 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")) { - assertTrue("No result", resultSet.next()); - assertEquals("Value", 12345678909324L, resultSet.getObject("a")); - assertFalse("Too many result", resultSet.next()); + 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 - public void testTcJdbcUri() throws Exception { - try (Connection connection = DriverManager.getConnection(format("jdbc:tc:presto:%s://hostname/", PrestoContainer.DEFAULT_TAG))) { + 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 - assertEquals(connection.getMetaData().getDatabaseMajorVersion(), parseInt(PrestoContainer.DEFAULT_TAG)); + assertThat(Integer.parseInt(PrestoContainer.DEFAULT_TAG)) + .isEqualTo(connection.getMetaData().getDatabaseMajorVersion()); // Verify transactions with tc: JDBC connection URI - assertTrue("Is autocommit", connection.getAutoCommit()); + assertThat(connection.getAutoCommit()).as("Is autocommit").isTrue(); connection.setAutoCommit(false); - assertFalse("Is autocommit", connection.getAutoCommit()); - assertEquals("Transaction isolation", Connection.TRANSACTION_READ_UNCOMMITTED, connection.getTransactionIsolation()); + assertThat(connection.getAutoCommit()).as("Is autocommit").isFalse(); + assertThat(connection.getTransactionIsolation()) + .as("Transaction isolation") + .isEqualTo(Connection.TRANSACTION_READ_UNCOMMITTED); try (Statement statement = connection.createStatement()) { - assertEquals("Update result", 0, statement.executeUpdate("CREATE TABLE memory.default.test_tc(a bigint)")); - try (ResultSet resultSet = statement.executeQuery("SELECT sum(cast(node_version AS bigint)) AS v FROM system.runtime.nodes")) { - assertTrue(resultSet.next()); - assertEquals(PrestoContainer.DEFAULT_TAG, resultSet.getString("v")); - assertFalse(resultSet.next()); + 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); - assertTrue("Is autocommit", connection.getAutoCommit()); - assertEquals("Transaction isolation should be retained", Connection.TRANSACTION_READ_UNCOMMITTED, connection.getTransactionIsolation()); + 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 index d1a64c92aaa..02e5b2a0485 100644 --- a/modules/presto/src/test/java/org/testcontainers/jdbc/presto/PrestoJDBCDriverTest.java +++ b/modules/presto/src/test/java/org/testcontainers/jdbc/presto/PrestoJDBCDriverTest.java @@ -1,21 +1,17 @@ package org.testcontainers.jdbc.presto; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; import org.testcontainers.jdbc.AbstractJDBCDriverTest; +import java.util.Arrays; import java.util.EnumSet; -import static java.util.Arrays.asList; +class PrestoJDBCDriverTest extends AbstractJDBCDriverTest { -@RunWith(Parameterized.class) -public class PrestoJDBCDriverTest extends AbstractJDBCDriverTest { - - @Parameterized.Parameters(name = "{index} - {0}") public static Iterable data() { - return asList( - new Object[][]{ - {"jdbc:tc:presto:344://hostname/", EnumSet.of(Options.PmdKnownBroken)}, - }); + return Arrays.asList( + new Object[][] { // + { "jdbc:tc:presto:344://hostname/", EnumSet.of(Options.PmdKnownBroken) }, + } + ); } } 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 index b574519a939..9dee1058aa5 100644 --- a/modules/pulsar/build.gradle +++ b/modules/pulsar/build.gradle @@ -1,9 +1,9 @@ description = "Testcontainers :: Pulsar" dependencies { - compile project(':testcontainers') + api project(':testcontainers') - testCompile group: 'org.apache.pulsar', name: 'pulsar-client', version: '2.7.0' - testCompile group: 'org.assertj', name: 'assertj-core', version: '3.18.1' - testCompile group: 'org.apache.pulsar', name: 'pulsar-client-admin', version: '2.7.0' + 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 index b3c836b56a5..7305fbd622c 100644 --- a/modules/pulsar/src/main/java/org/testcontainers/containers/PulsarContainer.java +++ b/modules/pulsar/src/main/java/org/testcontainers/containers/PulsarContainer.java @@ -5,22 +5,46 @@ import org.testcontainers.utility.DockerImageName; /** - * This container wraps Apache Pulsar running in standalone mode + * 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; - public static final String METRICS_ENDPOINT = "/metrics"; + + 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 = "2.2.0"; + 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 use {@link #PulsarContainer(DockerImageName)} instead */ @Deprecated public PulsarContainer() { @@ -28,7 +52,7 @@ public PulsarContainer() { } /** - * @deprecated use {@link PulsarContainer(DockerImageName)} instead + * @deprecated use {@link #PulsarContainer(DockerImageName)} instead */ @Deprecated public PulsarContainer(String pulsarVersion) { @@ -37,26 +61,15 @@ public PulsarContainer(String pulsarVersion) { public PulsarContainer(final DockerImageName dockerImageName) { super(dockerImageName); - - dockerImageName.assertCompatibleWith(DockerImageName.parse("apachepulsar/pulsar")); - + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, DockerImageName.parse("apachepulsar/pulsar-all")); withExposedPorts(BROKER_PORT, BROKER_HTTP_PORT); - withCommand("/pulsar/bin/pulsar", "standalone", "--no-functions-worker", "-nss"); - waitingFor(Wait.forHttp(METRICS_ENDPOINT).forStatusCode(200).forPort(BROKER_HTTP_PORT)); + setWaitStrategy(waitAllStrategy); } @Override protected void configure() { super.configure(); - - if (functionsWorkerEnabled) { - withCommand("/pulsar/bin/pulsar", "standalone"); - waitingFor( - new WaitAllStrategy() - .withStrategy(waitStrategy) - .withStrategy(Wait.forLogMessage(".*Function worker service started.*", 1)) - ); - } + setupCommandAndEnv(); } public PulsarContainer withFunctionsWorker() { @@ -64,6 +77,11 @@ public PulsarContainer withFunctionsWorker() { return this; } + public PulsarContainer withTransactions() { + transactionsEnabled = true; + return this; + } + public String getPulsarBrokerUrl() { return String.format("pulsar://%s:%s", getHost(), getMappedPort(BROKER_PORT)); } @@ -71,4 +89,31 @@ public String getPulsarBrokerUrl() { 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/containers/PulsarContainerTest.java b/modules/pulsar/src/test/java/org/testcontainers/containers/PulsarContainerTest.java deleted file mode 100644 index f2354d98ef7..00000000000 --- a/modules/pulsar/src/test/java/org/testcontainers/containers/PulsarContainerTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package org.testcontainers.containers; - -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.junit.Test; -import org.testcontainers.utility.DockerImageName; - -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -public class PulsarContainerTest { - - public static final String TEST_TOPIC = "test_topic"; - private static final DockerImageName PULSAR_IMAGE = DockerImageName.parse("apachepulsar/pulsar:2.2.0"); - - @Test - public void testUsage() throws Exception { - try (PulsarContainer pulsar = new PulsarContainer(PULSAR_IMAGE)) { - pulsar.start(); - testPulsarFunctionality(pulsar.getPulsarBrokerUrl()); - } - } - - @Test - public void shouldNotEnableFunctionsWorkerByDefault() throws Exception { - try (PulsarContainer pulsar = new PulsarContainer("2.5.1")) { - pulsar.start(); - - PulsarAdmin pulsarAdmin = PulsarAdmin.builder() - .serviceHttpUrl(pulsar.getHttpServiceUrl()) - .build(); - - assertThatThrownBy(() -> pulsarAdmin.functions().getFunctions("public", "default")) - .isInstanceOf(PulsarAdminException.class); - } - } - - @Test - public void shouldWaitForFunctionsWorkerStarted() throws Exception { - try (PulsarContainer pulsar = new PulsarContainer("2.5.1").withFunctionsWorker()) { - pulsar.start(); - - PulsarAdmin pulsarAdmin = PulsarAdmin.builder() - .serviceHttpUrl(pulsar.getHttpServiceUrl()) - .build(); - - assertThat(pulsarAdmin.functions().getFunctions("public", "default")).hasSize(0); - } - } - - 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"); - } - } - -} 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 index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/pulsar/src/test/resources/logback-test.xml +++ b/modules/pulsar/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + 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 index a7388ebc6d2..9c93885fd6f 100644 --- a/modules/r2dbc/build.gradle +++ b/modules/r2dbc/build.gradle @@ -5,16 +5,13 @@ plugins { description = "Testcontainers :: R2DBC" dependencies { - annotationProcessor 'com.google.auto.service:auto-service:1.0-rc6' - compileOnly 'com.google.auto.service:auto-service:1.0-rc6' + api project(':testcontainers') + api 'io.r2dbc:r2dbc-spi:0.9.0.RELEASE' - compile project(':testcontainers') - compile 'io.r2dbc:r2dbc-spi:0.8.1.RELEASE' + testImplementation 'io.r2dbc:r2dbc-postgresql:0.8.13.RELEASE' + testImplementation project(':testcontainers-postgresql') - testCompile 'org.assertj:assertj-core:3.14.0' - testCompile 'io.r2dbc:r2dbc-postgresql:0.8.1.RELEASE' - testCompile project(':postgresql') - - testFixturesCompile 'io.projectreactor:reactor-core:3.3.4.RELEASE' - testFixturesCompile 'org.assertj:assertj-core:3.14.0' + 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 index e64d19a9f49..3f4fd7e4b1e 100644 --- a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/CancellableSubscription.java +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/CancellableSubscription.java @@ -9,8 +9,7 @@ class CancellableSubscription implements Subscription { private final AtomicBoolean cancelled = new AtomicBoolean(); @Override - public void request(long n) { - } + public void request(long n) {} @Override public void cancel() { diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/ConnectionPublisher.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/ConnectionPublisher.java index 489d545429d..d1b1c0e43f3 100644 --- a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/ConnectionPublisher.java +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/ConnectionPublisher.java @@ -56,8 +56,8 @@ synchronized void transitionTo(SubscriptionState newState) { } abstract class SubscriptionState implements Subscription { - void enter() { - } + + void enter() {} } class WaitRequestSubscriptionState extends SubscriptionState { @@ -68,8 +68,7 @@ public void request(long n) { } @Override - public void cancel() { - } + public void cancel() {} } class WaitFutureCompletionSubscriptionState extends SubscriptionState { @@ -93,8 +92,7 @@ void enter() { } @Override - public void request(long n) { - } + public void request(long n) {} @Override public void cancel() { diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/EmptySubscription.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/EmptySubscription.java index 7828d121c6f..5cd2ffbd894 100644 --- a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/EmptySubscription.java +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/EmptySubscription.java @@ -6,12 +6,8 @@ enum EmptySubscription implements Subscription { INSTANCE; @Override - public void request(long n) { - - } + public void request(long n) {} @Override - public void cancel() { - - } + 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 index f642a60d67c..327ad6ab9ca 100644 --- a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/Hidden.java +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/Hidden.java @@ -1,6 +1,5 @@ package org.testcontainers.r2dbc; -import com.google.auto.service.AutoService; import io.r2dbc.spi.ConnectionFactory; import io.r2dbc.spi.ConnectionFactoryOptions; import io.r2dbc.spi.ConnectionFactoryProvider; @@ -10,7 +9,6 @@ */ class Hidden { - @AutoService(ConnectionFactoryProvider.class) public static final class TestcontainersR2DBCConnectionFactoryProvider implements ConnectionFactoryProvider { public static final String DRIVER = "tc"; @@ -42,7 +40,7 @@ private ConnectionFactoryOptions removeProxying(ConnectionFactoryOptions options // | DRIVER | tc | postgres | // | PROTOCOL | postgres | | - String protocol = options.getRequiredValue(ConnectionFactoryOptions.PROTOCOL); + String protocol = (String) options.getRequiredValue(ConnectionFactoryOptions.PROTOCOL); if (protocol.trim().length() == 0) { throw new IllegalArgumentException("Invalid protocol: " + protocol); } @@ -52,7 +50,8 @@ private ConnectionFactoryOptions removeProxying(ConnectionFactoryOptions options // when protocol does NOT contain COLON, the length becomes 1 String protocolDelegate = protocols.length == 2 ? protocols[1] : ""; - return ConnectionFactoryOptions.builder() + return ConnectionFactoryOptions + .builder() .from(options) .option(ConnectionFactoryOptions.DRIVER, driverDelegate) .option(ConnectionFactoryOptions.PROTOCOL, protocolDelegate) @@ -68,6 +67,5 @@ public boolean supports(ConnectionFactoryOptions options) { 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 index a303cbafff8..8bf0401fe69 100644 --- a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainer.java +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainer.java @@ -4,6 +4,5 @@ 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 index 287407dc15e..ded7aaa9ffd 100644 --- a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainerProvider.java +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainerProvider.java @@ -5,11 +5,11 @@ 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"); @@ -22,7 +22,7 @@ public interface R2DBCDatabaseContainerProvider { default ConnectionFactoryMetadata getMetadata(ConnectionFactoryOptions options) { ConnectionFactoryOptions.Builder builder = options.mutate(); if (!options.hasOption(ConnectionFactoryOptions.HOST)) { - builder.option(ConnectionFactoryOptions.HOST, "localhost"); + builder.option(ConnectionFactoryOptions.HOST, DockerClientFactory.instance().dockerHostIpAddress()); } if (!options.hasOption(ConnectionFactoryOptions.PORT)) { builder.option(ConnectionFactoryOptions.PORT, 65535); diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactory.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactory.java index 691775a94f8..bada076eec9 100644 --- a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactory.java +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactory.java @@ -36,34 +36,36 @@ class TestcontainersR2DBCConnectionFactory implements ConnectionFactory, Closeab 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)); + 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 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) - ); - }); } - ); + return future.thenApply(it -> { + return ConnectionFactories.find(it.configure(options)); + }); + }); } @Override @@ -99,5 +101,4 @@ public Publisher close() { } }; } - } 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 index 6fad93f0e2a..7a76b436e22 100644 --- a/modules/r2dbc/src/test/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactoryTest.java +++ b/modules/r2dbc/src/test/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactoryTest.java @@ -6,22 +6,21 @@ import io.r2dbc.spi.ConnectionFactories; import io.r2dbc.spi.ConnectionFactory; import io.r2dbc.spi.Result; -import org.assertj.core.api.Assertions; -import org.junit.Test; +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; -public class TestcontainersR2DBCConnectionFactoryTest { +class TestcontainersR2DBCConnectionFactoryTest { @Test - public void failsOnUnknownProvider() { + void failsOnUnknownProvider() { String nonExistingProvider = UUID.randomUUID().toString(); - Assertions - .assertThatThrownBy(() -> { + assertThatThrownBy(() -> { ConnectionFactories.get(String.format("r2dbc:tc:%s:///db", nonExistingProvider)); }) .hasMessageContaining("Missing provider") @@ -29,7 +28,7 @@ public void failsOnUnknownProvider() { } @Test - public void reusesUntilConnectionFactoryIsClosed() { + void reusesUntilConnectionFactoryIsClosed() { String url = "r2dbc:tc:postgresql:///db?TC_IMAGE_TAG=10-alpine"; ConnectionFactory connectionFactory = ConnectionFactories.get(url); @@ -48,16 +47,15 @@ public void reusesUntilConnectionFactoryIsClosed() { 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 - ); + 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(); @@ -65,8 +63,7 @@ public void reusesUntilConnectionFactoryIsClosed() { close(connectionFactory); - Assertions - .assertThatThrownBy(select::blockFirst) + 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 diff --git a/modules/r2dbc/src/test/resources/logback-test.xml b/modules/r2dbc/src/test/resources/logback-test.xml index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/r2dbc/src/test/resources/logback-test.xml +++ b/modules/r2dbc/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + diff --git a/modules/r2dbc/src/testFixtures/java/org/testcontainers/r2dbc/AbstractR2DBCDatabaseContainerTest.java b/modules/r2dbc/src/testFixtures/java/org/testcontainers/r2dbc/AbstractR2DBCDatabaseContainerTest.java index c21c7078410..ed0a7fba70f 100644 --- a/modules/r2dbc/src/testFixtures/java/org/testcontainers/r2dbc/AbstractR2DBCDatabaseContainerTest.java +++ b/modules/r2dbc/src/testFixtures/java/org/testcontainers/r2dbc/AbstractR2DBCDatabaseContainerTest.java @@ -6,7 +6,7 @@ import io.r2dbc.spi.ConnectionFactory; import io.r2dbc.spi.ConnectionFactoryMetadata; import io.r2dbc.spi.ConnectionFactoryOptions; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.testcontainers.containers.GenericContainer; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -19,12 +19,16 @@ public abstract class AbstractR2DBCDatabaseContainerTest it.map((row, meta) -> (Number) row.get(0))) .blockFirst(); - assertThat(result) - .isNotNull() - .returns(expected, Number::intValue); + 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 index 73524c2da18..1b9cd99168b 100644 --- a/modules/rabbitmq/build.gradle +++ b/modules/rabbitmq/build.gradle @@ -1,8 +1,8 @@ -description = "TestContainers :: RabbitMQ" +description = "Testcontainers :: RabbitMQ" dependencies { - compile project(":testcontainers") - testCompile 'com.rabbitmq:amqp-client:5.7.0' - testCompile 'org.assertj:assertj-core:3.12.2' - compileOnly 'org.jetbrains:annotations:20.0.0' + 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 index edbffcf3174..6db9f6b4cf0 100644 --- a/modules/rabbitmq/src/main/java/org/testcontainers/containers/RabbitMQContainer.java +++ b/modules/rabbitmq/src/main/java/org/testcontainers/containers/RabbitMQContainer.java @@ -9,40 +9,54 @@ import org.testcontainers.utility.MountableFile; import java.io.IOException; -import java.time.Duration; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Set; -import static java.lang.String.join; -import static java.util.Arrays.asList; - /** - * Testcontainer for RabbitMQ. + * Testcontainers implementation for RabbitMQ. + *

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

+ * Exposed ports: + *

    + *
  • 5671 (AMQPS)
  • + *
  • 5672 (AMQP)
  • + *
  • 15671 (HTTPS)
  • + *
  • 15672 (HTTP)
  • + *
* - * @author Martin Greber + * @deprecated use {@link org.testcontainers.rabbitmq.RabbitMQContainer} instead. */ +@Deprecated public class RabbitMQContainer extends GenericContainer { /** - * The image defaults to the official RabbitmQ image: RabbitMQ. + * 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 use {@link #RabbitMQContainer(DockerImageName)} instead */ @Deprecated public RabbitMQContainer() { @@ -60,26 +74,25 @@ public RabbitMQContainer(String 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); - this.waitStrategy = Wait. - forLogMessage(".*Server startup complete.*", 1) - .withStartupTimeout(Duration.ofSeconds(60)); + waitingFor(Wait.forLogMessage(".*Server startup complete.*", 1)); } @Override protected void configure() { - if (adminPassword != null) { - addEnv("RABBITMQ_DEFAULT_PASS", adminPassword); + 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])); @@ -96,11 +109,14 @@ protected void containerIsStarted(InspectContainerResponse containerInfo) { * @return The admin password for the admin account */ public String getAdminPassword() { - return adminPassword; + return this.adminPassword; } + /** + * @return The admin user for the admin account + */ public String getAdminUsername() { - return adminUsername; + return this.adminUsername; } public Integer getAmqpPort() { @@ -147,6 +163,17 @@ 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
) * @@ -159,218 +186,494 @@ public RabbitMQContainer withAdminPassword(final String adminPassword) { } public enum SslVerification { - VERIFY_NONE("verify_none"), VERIFY_PEER("verify_peer"); + VERIFY_NONE("verify_none"), + VERIFY_PEER("verify_peer"); SslVerification(String value) { this.value = value; } - private final String value; + private final String value; } public RabbitMQContainer withSSL( - final MountableFile keyFile, - final MountableFile certFile, - final MountableFile caFile, - final SslVerification verify, - boolean failIfNoCert, - int verificationDepth) { - + 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)); + .withEnv("RABBITMQ_SSL_DEPTH", String.valueOf(verificationDepth)); } public RabbitMQContainer withSSL( - final MountableFile keyFile, - final MountableFile certFile, - final MountableFile caFile, - final SslVerification verify, - boolean failIfNoCert) { - + 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)); + .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) { - + 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"); + .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<>(asList("rabbitmq-plugins", "enable")); - command.addAll(asList(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(asList("rabbitmqadmin", "declare", "binding", + 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)); + "destination=" + destination, + "routing_key=" + routingKey, + "destination_type=" + destinationType, + "arguments=" + toJson(arguments) + ) + ); return self(); } - public RabbitMQContainer withBinding(String source, String destination, Map arguments, String routingKey, String destinationType) { - values.add(asList("rabbitmqadmin", "declare", "binding", + /** + * @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))); + "arguments=" + toJson(arguments) + ) + ); return self(); } + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated public RabbitMQContainer withParameter(String component, String name, String value) { - values.add(asList("rabbitmqadmin", "declare", "parameter", + values.add( + Arrays.asList( + "rabbitmqadmin", + "declare", + "parameter", "component=" + component, "name=" + name, - "value=" + value)); + "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(asList("rabbitmqadmin", "declare", "permission", + values.add( + Arrays.asList( + "rabbitmqadmin", + "declare", + "permission", "vhost=" + vhost, "user=" + user, "configure=" + configure, "write=" + write, - "read=" + read)); + "read=" + read + ) + ); return self(); } + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated public RabbitMQContainer withUser(String name, String password) { - values.add(asList("rabbitmqadmin", "declare", "user", - "name=" + name, - "password=" + password, - "tags=")); + 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(asList("rabbitmqadmin", "declare", "user", + values.add( + Arrays.asList( + "rabbitmqadmin", + "declare", + "user", "name=" + name, "password=" + password, - "tags=" + join(",", tags))); + "tags=" + String.join(",", tags) + ) + ); return self(); } + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated public RabbitMQContainer withPolicy(String name, String pattern, Map definition) { - values.add(asList("rabbitmqadmin", "declare", "policy", + values.add( + Arrays.asList( + "rabbitmqadmin", + "declare", + "policy", "name=" + name, "pattern=" + pattern, - "definition=" + toJson(definition))); + "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(asList("rabbitmqadmin", "declare", "policy", + values.add( + Arrays.asList( + "rabbitmqadmin", + "declare", + "policy", "--vhost=" + vhost, "name=" + name, "pattern=" + pattern, - "definition=" + toJson(definition))); + "definition=" + toJson(definition) + ) + ); return self(); } - public RabbitMQContainer withPolicy(String name, String pattern, Map definition, int priority, String applyTo) { - values.add(asList("rabbitmqadmin", "declare", "policy", + /** + * @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))); + "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<>(asList("rabbitmqadmin", "declare", "operator_policy", - "name=" + name, - "pattern=" + pattern, - "definition=" + toJson(definition)))); + values.add( + new ArrayList<>( + Arrays.asList( + "rabbitmqadmin", + "declare", + "operator_policy", + "name=" + name, + "pattern=" + pattern, + "definition=" + toJson(definition) + ) + ) + ); return self(); } - public RabbitMQContainer withOperatorPolicy(String name, String pattern, Map definition, int priority, String applyTo) { - values.add(asList("rabbitmqadmin", "declare", "operator_policy", + /** + * @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))); + "definition=" + toJson(definition) + ) + ); return self(); } + /** + * @deprecated use {@link #execInContainer(String...)} instead + */ + @Deprecated public RabbitMQContainer withVhost(String name) { - values.add(asList("rabbitmqadmin", "declare", "vhost", - "name=" + 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(asList("rabbitmqadmin", "declare", "vhost", - "name=" + name, - "tracing=" + 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(asList("rabbitmqadmin", "declare", "vhost_limit", - "vhost=" + vhost, - "name=" + name, - "value=" + 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(asList("rabbitmqadmin", "declare", "queue", - "name=" + name)); + values.add(Arrays.asList("rabbitmqadmin", "declare", "queue", "name=" + name)); return self(); } - public RabbitMQContainer withQueue(String name, boolean autoDelete, boolean durable, Map arguments) { - values.add(asList("rabbitmqadmin", "declare", "queue", + /** + * @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))); + "arguments=" + toJson(arguments) + ) + ); return self(); } - public RabbitMQContainer withExchange(String name, String type) { - values.add(asList("rabbitmqadmin", "declare", "exchange", + /** + * @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, - "type=" + type)); + "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(); } - public RabbitMQContainer withExchange(String name, String type, boolean autoDelete, boolean internal, boolean durable, Map arguments) { - values.add(asList("rabbitmqadmin", "declare", "exchange", - "name=" + name, - "type=" + type, - "auto_delete=" + autoDelete, - "internal=" + internal, - "durable=" + durable, - "arguments=" + toJson(arguments))); + /** + * @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(); } - public RabbitMQContainer withExchange(String vhost, String name, String type, boolean autoDelete, boolean internal, boolean durable, Map arguments) { - values.add(asList("rabbitmqadmin", "--vhost=" + vhost, "declare", "exchange", + /** + * @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))); + "arguments=" + toJson(arguments) + ) + ); return self(); } @@ -381,7 +684,6 @@ public RabbitMQContainer withExchange(String vhost, String name, String type, bo * @return This container. */ public RabbitMQContainer withRabbitMQConfig(MountableFile rabbitMQConf) { - return withRabbitMQConfigSysctl(rabbitMQConf); } @@ -396,7 +698,7 @@ public RabbitMQContainer withRabbitMQConfig(MountableFile rabbitMQConf) { * @return This container. */ public RabbitMQContainer withRabbitMQConfigSysctl(MountableFile rabbitMQConf) { - withEnv("RABBITMQ_CONFIG_FILE", "/etc/rabbitmq/rabbitmq-custom"); + withEnv("RABBITMQ_CONFIG_FILE", "/etc/rabbitmq/rabbitmq-custom.conf"); return withCopyFileToContainer(rabbitMQConf, "/etc/rabbitmq/rabbitmq-custom.conf"); } 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/containers/RabbitMQContainerJUnitIntegrationTest.java b/modules/rabbitmq/src/test/java/org/testcontainers/containers/RabbitMQContainerJUnitIntegrationTest.java deleted file mode 100644 index 7473f470f6f..00000000000 --- a/modules/rabbitmq/src/test/java/org/testcontainers/containers/RabbitMQContainerJUnitIntegrationTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.testcontainers.containers; - -import org.junit.ClassRule; -import org.junit.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Test for basic functionality when used as a @ClassRule. - * - * @author Mrtin Greber - */ -public class RabbitMQContainerJUnitIntegrationTest { - - @ClassRule - public static RabbitMQContainer rabbitMQContainer = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE); - - @Test - public void shouldStart() { - assertThat(rabbitMQContainer.isRunning()).isTrue(); - } -} diff --git a/modules/rabbitmq/src/test/java/org/testcontainers/containers/RabbitMQContainerTest.java b/modules/rabbitmq/src/test/java/org/testcontainers/containers/RabbitMQContainerTest.java deleted file mode 100644 index 8be97deb710..00000000000 --- a/modules/rabbitmq/src/test/java/org/testcontainers/containers/RabbitMQContainerTest.java +++ /dev/null @@ -1,285 +0,0 @@ -package org.testcontainers.containers; - -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableSet; -import com.rabbitmq.client.Channel; -import com.rabbitmq.client.Connection; -import com.rabbitmq.client.ConnectionFactory; -import org.junit.Test; -import org.testcontainers.utility.MountableFile; - -import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManagerFactory; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -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 java.util.Collections; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.testcontainers.containers.RabbitMQContainer.SslVerification.VERIFY_PEER; -import static org.testcontainers.utility.MountableFile.forClasspathResource; - -/** - * @author Martin Greber - */ -public 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 - public void shouldCreateRabbitMQContainer() - { - try (RabbitMQContainer container = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE)) { - - assertThat(container.getAdminPassword()).isEqualTo("guest"); - assertThat(container.getAdminUsername()).isEqualTo("guest"); - - container.start(); - - assertThat(container.getAmqpsUrl()).isEqualTo( - String.format("amqps://%s:%d", container.getHost(), container.getMappedPort(DEFAULT_AMQPS_PORT))); - assertThat(container.getAmqpUrl()).isEqualTo( - String.format("amqp://%s:%d", container.getHost(), container.getMappedPort(DEFAULT_AMQP_PORT))); - assertThat(container.getHttpsUrl()).isEqualTo( - String.format("https://%s:%d", container.getHost(), container.getMappedPort(DEFAULT_HTTPS_PORT))); - assertThat(container.getHttpUrl()).isEqualTo( - String.format("http://%s:%d", container.getHost(), container.getMappedPort(DEFAULT_HTTP_PORT))); - - assertThat(container.getHttpsPort()).isEqualTo(container.getMappedPort(DEFAULT_HTTPS_PORT)); - assertThat(container.getHttpPort()).isEqualTo(container.getMappedPort(DEFAULT_HTTP_PORT)); - assertThat(container.getAmqpsPort()).isEqualTo(container.getMappedPort(DEFAULT_AMQPS_PORT)); - assertThat(container.getAmqpPort()).isEqualTo(container.getMappedPort(DEFAULT_AMQP_PORT)); - - assertThat(container.getLivenessCheckPortNumbers()).containsExactlyInAnyOrder( - container.getMappedPort(DEFAULT_AMQP_PORT), - container.getMappedPort(DEFAULT_AMQPS_PORT), - container.getMappedPort(DEFAULT_HTTP_PORT), - container.getMappedPort(DEFAULT_HTTPS_PORT) - ); - } - } - - @Test - public void shouldCreateRabbitMQContainerWithExchange() throws IOException, InterruptedException - { - try (RabbitMQContainer container = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE)) { - container.withExchange("test-exchange", "direct"); - - container.start(); - - assertThat(container.execInContainer("rabbitmqctl", "list_exchanges").getStdout()) - .containsPattern("test-exchange\\s+direct"); - } - } - - @Test - public void shouldCreateRabbitMQContainerWithExchangeInVhost() throws IOException, InterruptedException - { - try (RabbitMQContainer container = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE)) { - container.withVhost("test-vhost"); - container.withExchange("test-vhost", "test-exchange", "direct", false, false, false, Collections.emptyMap()); - - container.start(); - - assertThat(container.execInContainer("rabbitmqctl", "list_exchanges", "-p", "test-vhost").getStdout()) - .containsPattern("test-exchange\\s+direct"); - } - } - - @Test - public void shouldCreateRabbitMQContainerWithQueues() throws IOException, InterruptedException - { - try (RabbitMQContainer container = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE)) { - - container.withQueue("queue-one") - .withQueue("queue-two", false, true, ImmutableMap.of("x-message-ttl", 1000)); - - container.start(); - - assertThat(container.execInContainer("rabbitmqctl", "list_queues", "name", "arguments").getStdout()) - .containsPattern("queue-one"); - assertThat(container.execInContainer("rabbitmqctl", "list_queues", "name", "arguments").getStdout()) - .containsPattern("queue-two\\s.*x-message-ttl"); - } - } - - @Test - public void shouldMountConfigurationFile() - { - try (RabbitMQContainer container = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE)) { - - container.withRabbitMQConfig(MountableFile.forClasspathResource("/rabbitmq-custom.conf")); - container.start(); - - assertThat(container.getLogs()).contains("config file(s) : /etc/rabbitmq/rabbitmq-custom.conf"); - assertThat(container.getLogs()).doesNotContain(" (not found)"); - } - } - - - @Test - public void shouldMountConfigurationFileErlang() - { - try (RabbitMQContainer container = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE)) { - - container.withRabbitMQConfigErlang(MountableFile.forClasspathResource("/rabbitmq-custom.config")); - container.start(); - - assertThat(container.getLogs()).contains("config file(s) : /etc/rabbitmq/rabbitmq-custom.config"); - assertThat(container.getLogs()).doesNotContain(" (not found)"); - } - } - - - @Test - public void shouldMountConfigurationFileSysctl() - { - try (RabbitMQContainer container = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE)) { - - container.withRabbitMQConfigSysctl(MountableFile.forClasspathResource("/rabbitmq-custom.conf")); - container.start(); - - assertThat(container.getLogs()).contains("config file(s) : /etc/rabbitmq/rabbitmq-custom.conf"); - assertThat(container.getLogs()).doesNotContain(" (not found)"); - } - } - - @Test - public void shouldStartTheWholeEnchilada() throws IOException, InterruptedException - { - try (RabbitMQContainer container = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE)) { - container - .withVhost("vhost1") - .withVhostLimit("vhost1", "max-connections", 1) - .withVhost("vhost2", true) - .withExchange("direct-exchange", "direct") - .withExchange("topic-exchange", "topic") - .withQueue("queue1") - .withQueue("queue2", true, false, ImmutableMap.of("x-message-ttl", 1000)) - .withBinding("direct-exchange", "queue1") - .withUser("user1", "password1") - .withUser("user2", "password2", ImmutableSet.of("administrator")) - .withPermission("vhost1", "user1", ".*", ".*", ".*") - .withPolicy("max length policy", "^dog", ImmutableMap.of("max-length", 1), 1, "queues") - .withPolicy("alternate exchange policy", "^direct-exchange", ImmutableMap.of("alternate-exchange", "amq.direct")) - .withPolicy("vhost2", "ha-all", ".*", ImmutableMap.of("ha-mode", "all", "ha-sync-mode", "automatic")) - .withOperatorPolicy("operator policy 1", "^queue1", ImmutableMap.of("message-ttl", 1000), 1, "queues") - .withPluginsEnabled("rabbitmq_shovel", "rabbitmq_random_exchange"); - - container.start(); - - assertThat(container.execInContainer("rabbitmqadmin", "list", "queues") - .getStdout()) - .contains("queue1", "queue2"); - - assertThat(container.execInContainer("rabbitmqadmin", "list", "exchanges") - .getStdout()) - .contains("direct-exchange", "topic-exchange"); - - assertThat(container.execInContainer("rabbitmqadmin", "list", "bindings") - .getStdout()) - .contains("direct-exchange"); - - assertThat(container.execInContainer("rabbitmqadmin", "list", "users") - .getStdout()) - .contains("user1", "user2"); - - assertThat(container.execInContainer("rabbitmqadmin", "list", "policies") - .getStdout()) - .contains("max length policy", "alternate exchange policy"); - - assertThat(container.execInContainer("rabbitmqadmin", "list", "policies", "--vhost=vhost2") - .getStdout()) - .contains("ha-all", "ha-sync-mode"); - - assertThat(container.execInContainer("rabbitmqadmin", "list", "operator_policies") - .getStdout()) - .contains("operator policy 1"); - - assertThat(container.execInContainer("rabbitmq-plugins", "is_enabled", "rabbitmq_shovel", "--quiet") - .getStdout()) - .contains("rabbitmq_shovel is enabled"); - - assertThat(container.execInContainer("rabbitmq-plugins", "is_enabled", "rabbitmq_random_exchange", "--quiet") - .getStdout()) - .contains("rabbitmq_random_exchange is enabled"); - } - } - - @Test - public void shouldThrowExceptionForDodgyJson() - { - try (RabbitMQContainer container = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE)) { - - assertThatCode(() -> - container.withQueue( - "queue2", - true, - false, - ImmutableMap.of("x-message-ttl", container)) - ).hasMessageStartingWith("Failed to convert arguments into json"); - - } - } - - @Test - public void shouldWorkWithSSL() - { - try (RabbitMQContainer container = new RabbitMQContainer(RabbitMQTestImages.RABBITMQ_IMAGE)) { - container.withSSL( - forClasspathResource("/certs/server_key.pem", 0644), - forClasspathResource("/certs/server_certificate.pem", 0644), - forClasspathResource("/certs/ca_certificate.pem", 0644), - VERIFY_PEER, - true - ); - - container.start(); - - assertThatCode(() -> { - ConnectionFactory connectionFactory = new ConnectionFactory(); - connectionFactory.useSslProtocol(createSslContext( - "certs/client_key.p12", "password", - "certs/truststore.jks", "password")); - connectionFactory.enableHostnameVerification(); - connectionFactory.setUri(container.getAmqpsUrl()); - connectionFactory.setPassword(container.getAdminPassword()); - Connection connection = connectionFactory.newConnection(); - Channel channel = connection.openChannel().orElseThrow(() -> new RuntimeException("Failed to Open channel")); - channel.close(); - connection.close(); - }).doesNotThrowAnyException(); - } - } - - private SSLContext createSslContext(String keystoreFile, String keystorePassword, String truststoreFile, String truststorePassword) - throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException, UnrecoverableKeyException, KeyManagementException - { - ClassLoader classLoader = getClass().getClassLoader(); - - KeyStore ks = KeyStore.getInstance("PKCS12"); - ks.load(new FileInputStream(new File(classLoader.getResource(keystoreFile).getFile())), keystorePassword.toCharArray()); - KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509"); - kmf.init(ks, "password".toCharArray()); - - KeyStore trustStore = KeyStore.getInstance("PKCS12"); - trustStore.load(new FileInputStream(new File(classLoader.getResource(truststoreFile).getFile())), truststorePassword.toCharArray()); - TrustManagerFactory tmf = TrustManagerFactory.getInstance("SunX509"); - tmf.init(trustStore); - - SSLContext c = SSLContext.getInstance("TLSv1.2"); - c.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); - return c; - } -} 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/containers/RabbitMQTestImages.java b/modules/rabbitmq/src/test/java/org/testcontainers/rabbitmq/RabbitMQTestImages.java similarity index 82% rename from modules/rabbitmq/src/test/java/org/testcontainers/containers/RabbitMQTestImages.java rename to modules/rabbitmq/src/test/java/org/testcontainers/rabbitmq/RabbitMQTestImages.java index 0898cbdb350..56294f1c196 100644 --- a/modules/rabbitmq/src/test/java/org/testcontainers/containers/RabbitMQTestImages.java +++ b/modules/rabbitmq/src/test/java/org/testcontainers/rabbitmq/RabbitMQTestImages.java @@ -1,4 +1,4 @@ -package org.testcontainers.containers; +package org.testcontainers.rabbitmq; import org.testcontainers.utility.DockerImageName; 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 index 293829a353b..a2994c78602 100644 --- a/modules/rabbitmq/src/test/resources/rabbitmq-custom.conf +++ b/modules/rabbitmq/src/test/resources/rabbitmq-custom.conf @@ -1,2 +1 @@ -loopback_users.guest = false -listeners.tcp.default = 5555 +log.console.level = debug diff --git a/modules/rabbitmq/src/test/resources/rabbitmq-custom.config b/modules/rabbitmq/src/test/resources/rabbitmq-custom.config index a1c581542ce..353a854fc42 100644 --- a/modules/rabbitmq/src/test/resources/rabbitmq-custom.config +++ b/modules/rabbitmq/src/test/resources/rabbitmq-custom.config @@ -1,13 +1,9 @@ [ -{ rabbit, [ -{ loopback_users, [ ] }, - { tcp_listeners, [ 5555 ] }, - { ssl_listeners, [ ] }, - { default_vhost, <<"vhost">> }, - { hipe_compile, false } - ] }, - { rabbitmq_management, [ { listener, [ - { port, 15672 }, - { ssl, false } - ] } ] } +{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 index 7c6d81ca1bf..79b525b6185 100644 --- a/modules/selenium/build.gradle +++ b/modules/selenium/build.gradle @@ -1,16 +1,18 @@ description = "Testcontainers :: Selenium" dependencies { - compile project(':testcontainers') + api project(':testcontainers') - provided 'org.seleniumhq.selenium:selenium-remote-driver:3.141.59' - provided 'org.seleniumhq.selenium:selenium-chrome-driver:3.141.59' - testCompile 'org.seleniumhq.selenium:selenium-firefox-driver:3.141.59' - testCompile 'org.seleniumhq.selenium:selenium-support:3.141.59' + provided 'org.seleniumhq.selenium:selenium-remote-driver:4.10.0' + provided 'org.seleniumhq.selenium:selenium-chrome-driver:4.10.0' - testCompile 'org.mortbay.jetty:jetty:6.1.26' - testCompile project(':nginx') + 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' - compileOnly 'org.jetbrains:annotations:20.1.0' - testCompileClasspath 'org.jetbrains:annotations:20.0.0' + testImplementation 'org.mortbay.jetty:jetty:6.1.26' + testImplementation project(':testcontainers-nginx') + + compileOnly 'org.jetbrains:annotations:26.1.0' } 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 4a25ae486cd..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,26 +1,16 @@ package org.testcontainers.containers; -import static java.time.temporal.ChronoUnit.SECONDS; - 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 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; -import java.util.concurrent.TimeUnit; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.SystemUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.openqa.selenium.Capabilities; import org.openqa.selenium.chrome.ChromeOptions; -import org.openqa.selenium.remote.BrowserType; import org.openqa.selenium.remote.DesiredCapabilities; import org.openqa.selenium.remote.RemoteWebDriver; import org.rnorth.ducttape.timeouts.Timeouts; @@ -35,40 +25,79 @@ 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.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; + /** * 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 LinkableContainer, TestLifecycleAware { +@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 DockerImageName FIREFOX_DEBUG_IMAGE = DockerImageName.parse( + "selenium/standalone-firefox-debug" + ); - private static final DockerImageName CHROME_IMAGE = DockerImageName.parse("selenium/standalone-chrome-debug"); - private static final DockerImageName FIREFOX_IMAGE = DockerImageName.parse("selenium/standalone-firefox-debug"); private static final DockerImageName[] COMPATIBLE_IMAGES = new DockerImageName[] { CHROME_IMAGE, FIREFOX_IMAGE, - DockerImageName.parse("selenium/standalone-chrome"), - DockerImageName.parse("selenium/standalone-firefox") + 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 Capabilities capabilities; + private DockerImageName customImageName = null; @Nullable private RemoteWebDriver driver; + private VncRecordingMode recordingMode = VncRecordingMode.RECORD_FAILING; + private VncRecordingFormat recordingFormat; + private RecordingFileFactory recordingFileFactory; + private File vncRecordingDirectory; private VncRecordingContainer vncRecordingContainer = null; @@ -77,14 +106,7 @@ public class BrowserWebDriverContainer getLivenessCheckPorts() { @Override protected void configure() { - String seleniumVersion = SeleniumUtils.determineClasspathSeleniumVersion(); - if (capabilities == null) { - if (seleniumVersion.startsWith("2.")) { - logger().info("No capabilities provided, falling back to DesiredCapabilities.chrome()"); - capabilities = DesiredCapabilities.chrome(); - } else { - logger().info("No capabilities provided, falling back to ChromeOptions"); - capabilities = new ChromeOptions(); - } - } - if (recordingMode != VncRecordingMode.SKIP) { - if (vncRecordingDirectory == null) { try { vncRecordingDirectory = Files.createTempDirectory(TC_TEMP_DIR_PREFIX).toFile(); @@ -182,7 +184,8 @@ protected void configure() { withNetwork(Network.SHARED); } - vncRecordingContainer = new VncRecordingContainer(this) + vncRecordingContainer = + new VncRecordingContainer(this) .withVncPassword(DEFAULT_PASSWORD) .withVncPort(VNC_PORT) .withVideoFormat(recordingFormat); @@ -192,7 +195,10 @@ protected void configure() { customImageName.assertCompatibleWith(COMPATIBLE_IMAGES); super.setDockerImageName(customImageName.asCanonicalNameString()); } else { - DockerImageName standardImageForCapabilities = getStandardImageForCapabilities(capabilities, seleniumVersion); + DockerImageName standardImageForCapabilities = getStandardImageForCapabilities( + capabilities, + seleniumVersion + ); super.setDockerImageName(standardImageForCapabilities.asCanonicalNameString()); } @@ -212,7 +218,11 @@ protected void configure() { setCommand("/opt/bin/entry_point.sh"); if (getShmSize() == null) { - this.getBinds().add(new Bind("/dev/shm", new Volume("/dev/shm"), AccessMode.rw)); + if (SystemUtils.IS_OS_WINDOWS) { + withSharedMemorySize(512 * FileUtils.ONE_MB); + } else { + this.getBinds().add(new Bind("/dev/shm", new Volume("/dev/shm"), AccessMode.rw)); + } } /* @@ -237,14 +247,32 @@ public static String getDockerImageForCapabilities(Capabilities capabilities, St } private static DockerImageName getStandardImageForCapabilities(Capabilities capabilities, String seleniumVersion) { - String browserName = capabilities.getBrowserName(); + String browserName = capabilities == null ? BrowserType.CHROME : capabilities.getBrowserName(); + boolean supportsVncWithoutDebugImage = new ComparableVersion(seleniumVersion).isGreaterThanOrEqualTo("4"); + switch (browserName) { case BrowserType.CHROME: - return CHROME_IMAGE.withTag(seleniumVersion); + return (supportsVncWithoutDebugImage ? CHROME_IMAGE : CHROME_DEBUG_IMAGE).withTag(seleniumVersion); case BrowserType.FIREFOX: - return FIREFOX_IMAGE.withTag(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" + ); } } @@ -252,7 +280,7 @@ public URL getSeleniumAddress() { try { return new URL("http", getHost(), getMappedPort(SELENIUM_PORT), "/wd/hub"); } catch (MalformedURLException e) { - e.printStackTrace();// TODO + e.printStackTrace(); // TODO return null; } } @@ -273,11 +301,6 @@ public int getPort() { @Override protected void containerIsStarted(InspectContainerResponse containerInfo) { - driver = Unreliables.retryUntilSuccess(30, TimeUnit.SECONDS, - Timeouts.getWithTimeout(10, TimeUnit.SECONDS, - () -> - () -> new RemoteWebDriver(getSeleniumAddress(), capabilities))); - if (vncRecordingContainer != null) { LOGGER.debug("Starting VNC recording"); vncRecordingContainer.start(); @@ -291,8 +314,34 @@ protected void containerIsStarted(InspectContainerResponse containerInfo) { * 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; } @@ -309,6 +358,7 @@ public void stop() { } catch (Exception e) { LOGGER.debug("Failed to quit the driver", e); } + driver = null; } if (vncRecordingContainer != null) { @@ -317,6 +367,7 @@ public void stop() { } catch (Exception e) { LOGGER.debug("Failed to stop vncRecordingContainer", e); } + vncRecordingContainer = null; } super.stop(); @@ -337,7 +388,12 @@ private void retainRecordingIfNeeded(String prefix, boolean succeeded) { } if (shouldRecord) { - File recordingFile = recordingFileFactory.recordingFileForTest(vncRecordingDirectory, prefix, succeeded, vncRecordingContainer.getVideoFormat()); + File recordingFile = recordingFileFactory.recordingFileForTest( + vncRecordingDirectory, + prefix, + succeeded, + vncRecordingContainer.getVideoFormat() + ); LOGGER.info("Screen recordings for test {} will be stored at: {}", prefix, recordingFile); vncRecordingContainer.saveRecordingToFile(recordingFile); @@ -364,7 +420,11 @@ public SELF withRecordingMode(VncRecordingMode recordingMode, File vncRecordingD return withRecordingMode(recordingMode, vncRecordingDirectory, null); } - public SELF withRecordingMode(VncRecordingMode recordingMode, File vncRecordingDirectory, VncRecordingFormat recordingFormat) { + public SELF withRecordingMode( + VncRecordingMode recordingMode, + File vncRecordingDirectory, + VncRecordingFormat recordingFormat + ) { this.recordingMode = recordingMode; this.vncRecordingDirectory = vncRecordingDirectory; this.recordingFormat = recordingFormat; @@ -376,7 +436,31 @@ public SELF withRecordingFileFactory(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 index 62a1abd38db..3815f5de3e1 100644 --- a/modules/selenium/src/main/java/org/testcontainers/containers/DefaultRecordingFileFactory.java +++ b/modules/selenium/src/main/java/org/testcontainers/containers/DefaultRecordingFileFactory.java @@ -4,25 +4,36 @@ import java.text.SimpleDateFormat; import java.util.Date; -import static org.testcontainers.containers.VncRecordingContainer.VncRecordingFormat; - 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"; + 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); + return recordingFileForTest( + vncRecordingDirectory, + prefix, + succeeded, + VncRecordingContainer.DEFAULT_RECORDING_FORMAT + ); } @Override - public File recordingFileForTest(File vncRecordingDirectory, String prefix, boolean succeeded, VncRecordingFormat recordingFormat) { + 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, + final String fileName = String.format( + FILENAME_FORMAT, resultMarker, prefix, filenameDateFormat.format(new Date()), diff --git a/modules/selenium/src/main/java/org/testcontainers/containers/RecordingFileFactory.java b/modules/selenium/src/main/java/org/testcontainers/containers/RecordingFileFactory.java index aff607cf23d..9f3795f7cb8 100644 --- a/modules/selenium/src/main/java/org/testcontainers/containers/RecordingFileFactory.java +++ b/modules/selenium/src/main/java/org/testcontainers/containers/RecordingFileFactory.java @@ -1,21 +1,18 @@ package org.testcontainers.containers; -import org.junit.runner.Description; import org.testcontainers.containers.VncRecordingContainer.VncRecordingFormat; import java.io.File; public interface RecordingFileFactory { - - @Deprecated - default File recordingFileForTest(File vncRecordingDirectory, Description description, boolean succeeded) { - return recordingFileForTest(vncRecordingDirectory, description.getTestClass().getSimpleName() + "-" + description.getMethodName(), succeeded); - } - - default File recordingFileForTest(File vncRecordingDirectory, String prefix, boolean succeeded, VncRecordingFormat recordingFormat) { + 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 131f5a953ea..4fc0765cc86 100644 --- a/modules/selenium/src/main/java/org/testcontainers/containers/SeleniumUtils.java +++ b/modules/selenium/src/main/java/org/testcontainers/containers/SeleniumUtils.java @@ -45,19 +45,24 @@ public static String determineClasspathSeleniumVersion() { } } } - } 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; @@ -76,7 +81,7 @@ public static String getSeleniumVersionFromManifest(Manifest manifest) { } // Compatibility Selenium > 3.X - if(seleniumVersion == null) { + if (seleniumVersion == null) { Attributes seleniumInfo = manifest.getAttributes("Selenium"); if (seleniumInfo != null) { seleniumVersion = seleniumInfo.getValue("Selenium-Version"); 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/SeleniumTestImages.java b/modules/selenium/src/test/java/org/testcontainers/SeleniumTestImages.java deleted file mode 100644 index 82d19adf3b5..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/SeleniumTestImages.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.testcontainers; - -import org.testcontainers.utility.DockerImageName; - -public interface SeleniumTestImages { - DockerImageName NGINX_TEST_IMAGE = DockerImageName.parse("nginx:1.9.4"); -} diff --git a/modules/selenium/src/test/java/org/testcontainers/containers/DefaultRecordingFileFactoryTest.java b/modules/selenium/src/test/java/org/testcontainers/containers/DefaultRecordingFileFactoryTest.java index 9abea56c5f2..03540e54b01 100644 --- a/modules/selenium/src/test/java/org/testcontainers/containers/DefaultRecordingFileFactoryTest.java +++ b/modules/selenium/src/test/java/org/testcontainers/containers/DefaultRecordingFileFactoryTest.java @@ -1,60 +1,69 @@ package org.testcontainers.containers; import lombok.Value; -import org.junit.Test; -import org.junit.runner.Description; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; +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 java.lang.Boolean.FALSE; -import static java.lang.Boolean.TRUE; -import static java.lang.String.format; -import static java.time.LocalDateTime.now; -import static org.hamcrest.core.IsCollectionContaining.hasItem; -import static org.junit.Assert.assertThat; -import static org.junit.runner.Description.createTestDescription; +import static org.assertj.core.api.Assertions.assertThat; -@RunWith(Parameterized.class) +@ParameterizedClass +@MethodSource("data") @Value -public class DefaultRecordingFileFactoryTest { +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; - @Parameterized.Parameters public static Collection data() { Collection args = new ArrayList<>(); - args.add(new Object[]{"testMethod1", "FAILED", FALSE}); - args.add(new Object[]{"testMethod2", "PASSED", TRUE}); + args.add(new Object[] { "testMethod1", "FAILED", Boolean.FALSE }); + args.add(new Object[] { "testMethod2", "PASSED", Boolean.TRUE }); return args; } @Test - public void recordingFileThatShouldDescribeTheTestResultAtThePresentTime() throws Exception { + public void recordingFileThatShouldDescribeTheTestResultAtThePresentTime(TestInfo testInfo) throws Exception { File vncRecordingDirectory = Files.createTempDirectory("recording").toFile(); - Description description = createTestDescription(getClass().getCanonicalName(), methodName, Test.class); + String className = testInfo.getTestClass().orElseThrow(IllegalStateException::new).getSimpleName(); + String description = className + "-" + methodName; File recordingFile = factory.recordingFileForTest(vncRecordingDirectory, description, success); - String expectedFilePrefix = format("%s-%s-%s", prefix, getClass().getSimpleName(), methodName); + String expectedFilePrefix = String.format("%s-%s-%s", prefix, getClass().getSimpleName(), methodName); List expectedPossibleFileNames = Arrays.asList( - new File(vncRecordingDirectory, format("%s-%s.flv", expectedFilePrefix, now().format(DATETIME_FORMATTER))), - new File(vncRecordingDirectory, format("%s-%s.flv", expectedFilePrefix, now().minusSeconds(1L).format(DATETIME_FORMATTER))) + 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, hasItem(recordingFile)); + 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 db369824442..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/BaseWebDriverContainerTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package org.testcontainers.junit; - -import static java.lang.String.format; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; - -import java.util.concurrent.TimeUnit; -import org.jetbrains.annotations.NotNull; -import org.junit.ClassRule; -import org.openqa.selenium.By; -import org.openqa.selenium.WebElement; -import org.openqa.selenium.remote.RemoteWebDriver; -import org.testcontainers.containers.BrowserWebDriverContainer; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.Network; -import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; -import org.testcontainers.utility.DockerImageName; - -/** - * - */ -public class BaseWebDriverContainerTest { - - @ClassRule - public static Network NETWORK = Network.newNetwork(); - - @ClassRule - public static GenericContainer HELLO_WORLD = new GenericContainer<>(DockerImageName.parse("testcontainers/helloworld:1.1.0")) - .withNetwork(NETWORK) - .withNetworkAliases("helloworld") - .withExposedPorts(8080, 8081) - .waitingFor(new HttpWaitStrategy()); - - protected static void doSimpleExplore(BrowserWebDriverContainer rule) { - RemoteWebDriver driver = setupDriverFromRule(rule); - 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")); - - assertEquals("the index page contains the title 'Hello world'", - "Hello world", - title.getText().trim() - ); - } - - protected void assertBrowserNameIs(BrowserWebDriverContainer rule, String expectedName) { - RemoteWebDriver driver = setupDriverFromRule(rule); - String actual = driver.getCapabilities().getBrowserName(); - assertTrue(format("actual browser name is %s", actual), - actual.equals(expectedName)); - } - - @NotNull - private static RemoteWebDriver setupDriverFromRule(BrowserWebDriverContainer rule) { - RemoteWebDriver driver = rule.getWebDriver(); - driver.manage().timeouts().implicitlyWait(30, TimeUnit.SECONDS); - return driver; - } -} diff --git a/modules/selenium/src/test/java/org/testcontainers/junit/BrowserWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/junit/BrowserWebDriverContainerTest.java deleted file mode 100644 index 7b7d3a46733..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/BrowserWebDriverContainerTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package org.testcontainers.junit; - -import com.github.dockerjava.api.command.InspectContainerResponse; -import org.apache.commons.io.FileUtils; -import org.junit.Test; -import org.openqa.selenium.chrome.ChromeOptions; -import org.openqa.selenium.firefox.FirefoxOptions; -import org.testcontainers.containers.BrowserWebDriverContainer; - -import java.util.List; - -import static java.util.Collections.emptyList; -import static java.util.stream.Collectors.toList; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; - -public class BrowserWebDriverContainerTest { - - private static final String NO_PROXY_KEY = "no_proxy"; - - private static final String NO_PROXY_VALUE = "localhost,.noproxy-domain.com"; - - @Test - public void honorPresetNoProxyEnvironment() { - try ( - BrowserWebDriverContainer chromeWithNoProxySet = (BrowserWebDriverContainer) new BrowserWebDriverContainer() - .withCapabilities(new ChromeOptions()) - .withEnv(NO_PROXY_KEY, NO_PROXY_VALUE) - ) { - chromeWithNoProxySet.start(); - - Object noProxy = chromeWithNoProxySet.getEnvMap().get(NO_PROXY_KEY); - assertEquals("no_proxy should be preserved by the container rule", NO_PROXY_VALUE, noProxy); - } - } - - @Test - public void provideDefaultNoProxyEnvironmentIfNotSet() { - try ( - BrowserWebDriverContainer chromeWithoutNoProxySet = new BrowserWebDriverContainer() - .withCapabilities(new ChromeOptions()) - - ) { - chromeWithoutNoProxySet.start(); - - Object noProxy = chromeWithoutNoProxySet.getEnvMap().get(NO_PROXY_KEY); - assertEquals("no_proxy should be set to default if not already present", "localhost", noProxy); - } - } - - - @Test - public void createContainerWithShmVolume() { - try ( - BrowserWebDriverContainer webDriverContainer = new BrowserWebDriverContainer() - .withCapabilities(new FirefoxOptions()) - ) { - webDriverContainer.start(); - - final List shmVolumes = shmVolumes(webDriverContainer); - - assertEquals("Only one shm mount present", 1, shmVolumes.size()); - assertEquals("Shm mount source is correct", "/dev/shm", shmVolumes.get(0).getSource()); - assertEquals("Shm mount mode is correct", "rw", shmVolumes.get(0).getMode()); - } - } - - @Test - public void createContainerWithoutShmVolume() { - try ( - BrowserWebDriverContainer webDriverContainer = new BrowserWebDriverContainer<>() - .withSharedMemorySize(512 * FileUtils.ONE_MB) - .withCapabilities(new FirefoxOptions()) - ) { - webDriverContainer.start(); - - assertEquals("Shared memory size is configured", - 512 * FileUtils.ONE_MB, - webDriverContainer.getShmSize()); - - assertEquals("No shm mounts present", emptyList(), shmVolumes(webDriverContainer)); - } - } - - 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(toList()); - } -} 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 fdf56aa0134..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/ChromeRecordingWebDriverContainerTest.java +++ /dev/null @@ -1,213 +0,0 @@ -package org.testcontainers.junit; - - -import com.google.common.io.PatternFilenameFilter; -import org.junit.Rule; -import org.junit.Test; -import org.junit.experimental.runners.Enclosed; -import org.junit.rules.TemporaryFolder; -import org.junit.runner.RunWith; -import org.openqa.selenium.chrome.ChromeOptions; -import org.testcontainers.containers.BrowserWebDriverContainer; -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.utility.DockerImageName; -import org.testcontainers.utility.MountableFile; - -import java.io.File; -import java.io.IOException; -import java.time.Duration; -import java.util.Optional; -import java.util.concurrent.TimeUnit; - -import static java.time.temporal.ChronoUnit.SECONDS; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import static org.testcontainers.containers.BrowserWebDriverContainer.VncRecordingMode.RECORD_ALL; -import static org.testcontainers.containers.BrowserWebDriverContainer.VncRecordingMode.RECORD_FAILING; - -@RunWith(Enclosed.class) -public 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; - - public static class ChromeThatRecordsAllTests { - - @Rule - public TemporaryFolder vncRecordingDirectory = new TemporaryFolder(); - - @Test - public void recordingTestThatShouldBeRecordedAndRetainedInFlvFormatAsDefault() throws InterruptedException { - File target = vncRecordingDirectory.getRoot(); - try ( - // recordAll { - // To do this, simply add extra parameters to the rule constructor, so video will default to FLV format: - BrowserWebDriverContainer chrome = new BrowserWebDriverContainer<>() - .withCapabilities(new ChromeOptions()) - .withRecordingMode(RECORD_ALL, target) - // } - .withRecordingFileFactory(new DefaultRecordingFileFactory()) - .withNetwork(NETWORK) - ) { - File[] files = runSimpleExploreInContainer(chrome, "PASSED-.*\\.flv"); - assertEquals("Recorded file not found", 1, files.length); - } - } - - private File[] runSimpleExploreInContainer(BrowserWebDriverContainer container, String fileNamePattern) throws InterruptedException { - container.start(); - - TimeUnit.MILLISECONDS.sleep(MINIMUM_VIDEO_DURATION_MILLISECONDS); - doSimpleExplore(container); - container.afterTest(new TestDescription() { - @Override - public String getTestId() { - return getFilesystemFriendlyName(); - } - - @Override - public String getFilesystemFriendlyName() { - return "ChromeThatRecordsAllTests-recordingTestThatShouldBeRecordedAndRetained"; - } - }, Optional.empty()); - - return vncRecordingDirectory.getRoot().listFiles(new PatternFilenameFilter(fileNamePattern)); - } - - @Test - public void recordingTestShouldHaveFlvExtension() throws InterruptedException { - File target = vncRecordingDirectory.getRoot(); - try ( - // recordFlv { - // Set (explicitly) FLV format for recorded video: - BrowserWebDriverContainer chrome = new BrowserWebDriverContainer<>() - .withCapabilities(new ChromeOptions()) - .withRecordingMode(RECORD_ALL, target, VncRecordingFormat.FLV) - // } - .withRecordingFileFactory(new DefaultRecordingFileFactory()) - .withNetwork(NETWORK) - ) { - File[] files = runSimpleExploreInContainer(chrome, "PASSED-.*\\.flv"); - assertEquals("Recorded file not found", 1, files.length); - } - } - - @Test - public void recordingTestShouldHaveMp4Extension() throws InterruptedException { - File target = vncRecordingDirectory.getRoot(); - try ( - // recordMp4 { - // Set MP4 format for recorded video: - BrowserWebDriverContainer chrome = new BrowserWebDriverContainer<>() - .withCapabilities(new ChromeOptions()) - .withRecordingMode(RECORD_ALL, target, VncRecordingFormat.MP4) - // } - .withRecordingFileFactory(new DefaultRecordingFileFactory()) - .withNetwork(NETWORK) - ) { - File[] files = runSimpleExploreInContainer(chrome, "PASSED-.*\\.mp4"); - assertEquals("Recorded file not found", 1, files.length); - } - } - - @Test - public void recordingTestThatShouldHaveCorrectDuration() throws IOException, InterruptedException { - MountableFile mountableFile; - try ( - BrowserWebDriverContainer chrome = new BrowserWebDriverContainer<>() - .withCapabilities(new ChromeOptions()) - .withRecordingMode(RECORD_ALL, vncRecordingDirectory.getRoot()) - .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.2.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, SECONDS))) - .start(); - String ffmpegOutput = container.getLogs(); - - assertTrue("Duration is incorrect in:\n " + ffmpegOutput, - ffmpegOutput.contains("Duration: 00:") && !(ffmpegOutput.contains("Duration: 00:00:00.00"))); - } - } - } - - public static class ChromeThatRecordsFailingTests { - - @Rule - public TemporaryFolder vncRecordingDirectory = new TemporaryFolder(); - - @Test - public void recordingTestThatShouldBeRecordedButNotPersisted() { - try ( - // withRecordingFileFactory { - BrowserWebDriverContainer chrome = new BrowserWebDriverContainer<>() - // } - .withCapabilities(new ChromeOptions()) - // withRecordingFileFactory { - .withRecordingFileFactory(new CustomRecordingFileFactory()) - // } - .withNetwork(NETWORK) - ) { - chrome.start(); - - doSimpleExplore(chrome); - } - } - - @Test - public void recordingTestThatShouldBeRecordedAndRetained() throws InterruptedException { - File target = vncRecordingDirectory.getRoot(); - try ( - // recordFailing { - // or if you only want videos for test failures: - BrowserWebDriverContainer chrome = new BrowserWebDriverContainer<>() - .withCapabilities(new ChromeOptions()) - .withRecordingMode(RECORD_FAILING, target) - // } - .withRecordingFileFactory(new DefaultRecordingFileFactory()) - .withNetwork(NETWORK) - ) { - chrome.start(); - - TimeUnit.MILLISECONDS.sleep(MINIMUM_VIDEO_DURATION_MILLISECONDS); - doSimpleExplore(chrome); - 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.getRoot().list(new PatternFilenameFilter("FAILED-.*\\.flv")); - assertEquals("Recorded file not found", 1, files.length); - } - - } - - private static class CustomRecordingFileFactory extends DefaultRecordingFileFactory { - } - } -} 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 bea1909a145..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/ChromeWebDriverContainerTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.testcontainers.junit; - -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.openqa.selenium.chrome.ChromeOptions; -import org.testcontainers.containers.BrowserWebDriverContainer; - -/** - * - */ -public class ChromeWebDriverContainerTest extends BaseWebDriverContainerTest { - - // junitRule { - @Rule - public BrowserWebDriverContainer chrome = new BrowserWebDriverContainer<>() - .withCapabilities(new ChromeOptions()) - // } - .withNetwork(NETWORK); - - @Before - public void checkBrowserIsIndeedChrome() { - assertBrowserNameIs(chrome, "chrome"); - } - - @Test - public void simpleExploreTest() { - doSimpleExplore(chrome); - } -} diff --git a/modules/selenium/src/test/java/org/testcontainers/junit/ContainerWithoutCapabilitiesTest.java b/modules/selenium/src/test/java/org/testcontainers/junit/ContainerWithoutCapabilitiesTest.java deleted file mode 100644 index b1214d18b60..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/ContainerWithoutCapabilitiesTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.testcontainers.junit; - -import org.junit.Rule; -import org.junit.Test; -import org.testcontainers.containers.BrowserWebDriverContainer; - -public class ContainerWithoutCapabilitiesTest extends BaseWebDriverContainerTest{ - - @Rule - public BrowserWebDriverContainer chrome = new BrowserWebDriverContainer<>() - .withNetwork(NETWORK); - - @Test - public void chromeIsStartedIfNoCapabilitiesProvided() { - assertBrowserNameIs(chrome, "chrome"); - } - - @Test - public void simpleExploreTestWhenNoCapabilitiesProvided(){ - doSimpleExplore(chrome); - } -} diff --git a/modules/selenium/src/test/java/org/testcontainers/junit/CustomWaitTimeoutWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/junit/CustomWaitTimeoutWebDriverContainerTest.java deleted file mode 100644 index 8448dc40d53..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/CustomWaitTimeoutWebDriverContainerTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.testcontainers.junit; - -import org.junit.Rule; -import org.junit.Test; -import org.openqa.selenium.chrome.ChromeOptions; -import org.testcontainers.containers.BrowserWebDriverContainer; - -import java.time.Duration; - -import static java.time.temporal.ChronoUnit.SECONDS; - -/** - * - */ -public class CustomWaitTimeoutWebDriverContainerTest extends BaseWebDriverContainerTest { - - @Rule - public BrowserWebDriverContainer chromeWithCustomTimeout = new BrowserWebDriverContainer<>() - .withCapabilities(new ChromeOptions()) - .withStartupTimeout(Duration.of(30, SECONDS)) - .withNetwork(NETWORK); - - @Test - public void simpleExploreTest() { - doSimpleExplore(chromeWithCustomTimeout); - } -} 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 1cffec16bc6..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/FirefoxWebDriverContainerTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.testcontainers.junit; - -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.openqa.selenium.firefox.FirefoxOptions; -import org.testcontainers.containers.BrowserWebDriverContainer; - -/** - * - */ -public class FirefoxWebDriverContainerTest extends BaseWebDriverContainerTest { - - // junitRule { - @Rule - public BrowserWebDriverContainer firefox = new BrowserWebDriverContainer<>() - .withCapabilities(new FirefoxOptions()) - // } - .withNetwork(NETWORK); - - @Before - public void checkBrowserIsIndeedFirefox() { - assertBrowserNameIs(firefox, "firefox"); - } - - @Test - public void simpleExploreTest() { - doSimpleExplore(firefox); - } -} diff --git a/modules/selenium/src/test/java/org/testcontainers/junit/Selenium3xTest.java b/modules/selenium/src/test/java/org/testcontainers/junit/Selenium3xTest.java deleted file mode 100644 index 1c7d3fe494d..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/Selenium3xTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package org.testcontainers.junit; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -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. - */ -@RunWith(Parameterized.class) -public class Selenium3xTest { - - @Parameterized.Parameters(name = "tag: {0}") - public static String[] data() { - return new String[]{"3.4.0", "2.53.0", "2.45.0"}; - } - - @Parameterized.Parameter() - public String tag; - - @Test - public void testAdditionalStartupString() { - final DockerImageName imageName = DockerImageName - .parse("selenium/standalone-chrome-debug") - .withTag(tag); - try (BrowserWebDriverContainer chrome = new BrowserWebDriverContainer<>(imageName) - .withCapabilities(new ChromeOptions())) { - chrome.start(); - } - } -} 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 index d706d0fc390..b9c2125f93b 100644 --- a/modules/selenium/src/test/java/org/testcontainers/junit/SeleniumUtilsTest.java +++ b/modules/selenium/src/test/java/org/testcontainers/junit/SeleniumUtilsTest.java @@ -1,26 +1,25 @@ package org.testcontainers.junit; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.testcontainers.containers.SeleniumUtils; import java.io.IOException; import java.util.jar.Manifest; -import static org.rnorth.visibleassertions.VisibleAssertions.*; - +import static org.assertj.core.api.Assertions.assertThat; /** * Created by Julien LAMY */ -public class SeleniumUtilsTest { +class SeleniumUtilsTest { @Test - public void detectSeleniumVersionUnder3() throws IOException { + void detectSeleniumVersionUnder3() throws IOException { checkSeleniumVersionDetected("manifests/MANIFEST-2.45.0.MF", "2.45.0"); } @Test - public void detectSeleniumVersionUpper3() throws IOException { + void detectSeleniumVersionUpper3() throws IOException { checkSeleniumVersionDetected("manifests/MANIFEST-3.5.2.MF", "3.5.2"); } @@ -33,7 +32,8 @@ private void checkSeleniumVersionDetected(String urlManifest, String expectedVer Manifest manifest = new Manifest(); manifest.read(this.getClass().getClassLoader().getResourceAsStream(urlManifest)); String seleniumVersion = SeleniumUtils.getSeleniumVersionFromManifest(manifest); - assertEquals("Check if Selenium Version detected is the correct one.", expectedVersion, seleniumVersion); + 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 9a15c6b466d..00000000000 --- a/modules/selenium/src/test/java/org/testcontainers/junit/SpecificImageNameWebDriverContainerTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.testcontainers.junit; - -import org.junit.Rule; -import org.junit.Test; -import org.openqa.selenium.firefox.FirefoxOptions; -import org.testcontainers.containers.BrowserWebDriverContainer; -import org.testcontainers.utility.DockerImageName; - -public class SpecificImageNameWebDriverContainerTest extends BaseWebDriverContainerTest { - - private static final DockerImageName FIREFOX_IMAGE = DockerImageName - .parse("selenium/standalone-firefox:2.53.1-beryllium"); - - @Rule - public BrowserWebDriverContainer firefox = new BrowserWebDriverContainer<>(FIREFOX_IMAGE) - .withCapabilities(new FirefoxOptions()) - .withNetwork(NETWORK); - - @Test - public void simpleExploreTest() { - 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/junit/LocalServerWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/selenium/LocalServerWebDriverContainerTest.java similarity index 51% rename from modules/selenium/src/test/java/org/testcontainers/junit/LocalServerWebDriverContainerTest.java rename to modules/selenium/src/test/java/org/testcontainers/selenium/LocalServerWebDriverContainerTest.java index f7d626c15b5..e5819b11786 100644 --- a/modules/selenium/src/test/java/org/testcontainers/junit/LocalServerWebDriverContainerTest.java +++ b/modules/selenium/src/test/java/org/testcontainers/selenium/LocalServerWebDriverContainerTest.java @@ -1,40 +1,31 @@ -package org.testcontainers.junit; +package org.testcontainers.selenium; -import org.junit.*; +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.containers.BrowserWebDriverContainer; -import org.testcontainers.utility.TestEnvironment; +import org.testcontainers.Testcontainers; -import static org.apache.commons.lang.SystemUtils.IS_OS_MAC_OSX; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; +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) */ -public class LocalServerWebDriverContainerTest { +class LocalServerWebDriverContainerTest { - @Rule - public BrowserWebDriverContainer chrome = new BrowserWebDriverContainer().withCapabilities(new ChromeOptions()); - private int localPort; + public BrowserWebDriverContainer chrome = new BrowserWebDriverContainer("selenium/standalone-chrome:4.13.0") + .withAccessToHost(true); - /** - * 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()); - } + private int localPort; - @Before + @BeforeEach public void setupLocalServer() throws Exception { - + chrome.start(); // Set up a local Jetty HTTP server Server server = new Server(); server.addConnector(new SocketConnector()); @@ -48,19 +39,21 @@ public void setupLocalServer() throws Exception { } @Test - public void testConnection() { + void testConnection() { // getWebDriver { - RemoteWebDriver driver = chrome.getWebDriver(); + RemoteWebDriver driver = new RemoteWebDriver(chrome.getSeleniumAddress(), new ChromeOptions()); // } // Construct a URL that the browser container can access // getPage { - String hostIpAddress = chrome.getTestHostIpAddress(); - driver.get("http://" + hostIpAddress + ":" + localPort); + Testcontainers.exposeHostPorts(localPort); + driver.get("http://host.testcontainers.internal:" + 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); + 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 535e406fc13..83ef7a1a3ef 100644 --- a/modules/selenium/src/test/resources/logback-test.xml +++ b/modules/selenium/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + 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 index af3193be52a..509bce7c66f 100644 --- a/modules/solr/build.gradle +++ b/modules/solr/build.gradle @@ -1,7 +1,9 @@ description = "Testcontainers :: Solr" dependencies { - compile project(':testcontainers') - testCompile 'org.apache.solr:solr-solrj:8.3.0' + 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 index 4492ca0befe..19f0c7c4a8c 100644 --- a/modules/solr/src/main/java/org/testcontainers/containers/SolrClientUtils.java +++ b/modules/solr/src/main/java/org/testcontainers/containers/SolrClientUtils.java @@ -1,5 +1,13 @@ 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; @@ -11,19 +19,8 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; -import org.apache.commons.io.IOUtils; - -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; - /** * Utils class which can create collections and configurations. - * - * @author Simon Schneider */ public class SolrClientUtils { @@ -38,7 +35,13 @@ public class SolrClientUtils { * @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 { + 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); @@ -46,7 +49,6 @@ public static void uploadConfiguration(String hostname, int port, String configu byte[] configurationZipFile = generateConfigZipFile(solrConfig, solrSchema); executePost(url, configurationZipFile); - } /** @@ -58,7 +60,8 @@ public static void uploadConfiguration(String hostname, int port, String configu * @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 { + 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); @@ -73,16 +76,11 @@ public static void createCollection(String hostname, int port, String collection } 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(); - 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 = ""; @@ -97,7 +95,12 @@ private static void executePost(HttpUrl url, byte[] data) throws IOException { } } - private static HttpUrl generateSolrURL(String hostname, int port, List pathSegments, Map parameters) throws URISyntaxException { + 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); diff --git a/modules/solr/src/main/java/org/testcontainers/containers/SolrClientUtilsException.java b/modules/solr/src/main/java/org/testcontainers/containers/SolrClientUtilsException.java index 5ba0865502c..a3b2cfd77c6 100644 --- a/modules/solr/src/main/java/org/testcontainers/containers/SolrClientUtilsException.java +++ b/modules/solr/src/main/java/org/testcontainers/containers/SolrClientUtilsException.java @@ -1,6 +1,7 @@ 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 index 4bc2f19ec86..a921eb2f458 100644 --- a/modules/solr/src/main/java/org/testcontainers/containers/SolrContainer.java +++ b/modules/solr/src/main/java/org/testcontainers/containers/SolrContainer.java @@ -2,59 +2,67 @@ import com.github.dockerjava.api.command.InspectContainerResponse; import lombok.SneakyThrows; -import org.apache.commons.lang.StringUtils; +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; -import static java.time.temporal.ChronoUnit.SECONDS; - /** - * SolrContainer allows a solr container to be launched and controlled. + * 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 IMAGE = DEFAULT_IMAGE_NAME.getUnversionedPart(); - @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 use {@link #SolrContainer(DockerImageName)} instead */ @Deprecated public SolrContainer() { this(DEFAULT_IMAGE_NAME.withTag(DEFAULT_TAG)); } - /** - * @deprecated use {@link SolrContainer(DockerImageName)} instead - */ 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, SECONDS)); + 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) { @@ -96,17 +104,21 @@ public int getZookeeperPort() { @SneakyThrows protected void configure() { if (configuration.getSolrSchema() != null && configuration.getSolrConfiguration() == null) { - throw new IllegalStateException("Solr needs to have a configuration is you want to use a schema"); + throw new IllegalStateException("Solr needs to have a configuration if you want to use a schema"); } // Generate Command Builder - String command = "solr -f"; + String command = "solr start -f"; // Add Default Ports this.addExposedPort(SOLR_PORT); // Configure Zookeeper if (configuration.isZookeeper()) { this.addExposedPort(ZOOKEEPER_PORT); - command = "-DzkRun -h localhost"; + if (this.imageVersion.isGreaterThanOrEqualTo("9.7.0")) { + command = "-DzkRun --host localhost"; + } else { + command = "-DzkRun -h localhost"; + } } // Apply generated Command @@ -127,27 +139,30 @@ protected void waitUntilContainerStarted() { @SneakyThrows protected void containerIsStarted(InspectContainerResponse containerInfo) { if (!configuration.isZookeeper()) { - ExecResult result = execInContainer("solr", "create_core", "-c", configuration.getCollectionName()); + 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()); + throw new IllegalStateException( + "Unable to create solr core:\nStdout: " + result.getStdout() + "\nStderr:" + result.getStderr() + ); } return; } if (StringUtils.isNotEmpty(configuration.getConfigurationName())) { SolrClientUtils.uploadConfiguration( - getContainerIpAddress(), + getHost(), getSolrPort(), configuration.getConfigurationName(), configuration.getSolrConfiguration(), - configuration.getSolrSchema()); + configuration.getSolrSchema() + ); } SolrClientUtils.createCollection( - getContainerIpAddress(), + getHost(), getSolrPort(), configuration.getCollectionName(), - configuration.getConfigurationName()); + 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 index 1f35ce2a810..24035c8f4a9 100644 --- a/modules/solr/src/main/java/org/testcontainers/containers/SolrContainerConfiguration.java +++ b/modules/solr/src/main/java/org/testcontainers/containers/SolrContainerConfiguration.java @@ -1,18 +1,19 @@ package org.testcontainers.containers; -import java.net.URL; - import lombok.Data; -/** - * @author Simon Schneider - */ +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/containers/SolrContainerTest.java b/modules/solr/src/test/java/org/testcontainers/containers/SolrContainerTest.java deleted file mode 100644 index c2d19a3e61c..00000000000 --- a/modules/solr/src/test/java/org/testcontainers/containers/SolrContainerTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.testcontainers.containers; - -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.After; -import org.junit.Test; -import org.testcontainers.utility.DockerImageName; - -import java.io.IOException; - -import static org.hamcrest.CoreMatchers.containsString; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; - -/** - * @author Simon Schneider - */ -public class SolrContainerTest { - - private static final DockerImageName SOLR_IMAGE = DockerImageName.parse("solr:8.3.0"); - private SolrClient client = null; - - @After - public void stopRestClient() throws IOException { - if (client != null) { - client.close(); - client = null; - } - } - - @Test - public void solrCloudTest() throws IOException, SolrServerException { - try (SolrContainer container = new SolrContainer(SOLR_IMAGE)) { - container.start(); - SolrPingResponse response = getClient(container).ping("dummy"); - assertThat(response.getStatus(), is(0)); - assertThat(response.jsonStr(), containsString("zkConnected\":true")); - } - } - - @Test - public void solrStandaloneTest() throws IOException, SolrServerException { - try (SolrContainer container = new SolrContainer(SOLR_IMAGE).withZookeeper(false)) { - container.start(); - SolrPingResponse response = getClient(container).ping("dummy"); - assertThat(response.getStatus(), is(0)); - assertThat(response.jsonStr(), containsString("zkConnected\":null")); - } - } - - @Test - public void solrCloudPingTest() throws IOException, SolrServerException { - // solrContainerUsage { - // Create the solr container. - SolrContainer container = new SolrContainer(SOLR_IMAGE); - - // 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.getContainerIpAddress() + ":" + 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.getContainerIpAddress() + ":" + container.getSolrPort() + "/solr").build(); - } - return client; - } - -} 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 index a095bcb8a80..2ff27ecbace 100644 --- a/modules/spock/build.gradle +++ b/modules/spock/build.gradle @@ -5,20 +5,29 @@ plugins { description = "Testcontainers :: Spock-Extension" dependencies { - compile project(':testcontainers') - compile 'org.spockframework:spock-core:1.3-groovy-2.5' + api project(':testcontainers') + implementation 'org.spockframework:spock-core:2.3-groovy-4.0' - testCompile project(':selenium') - testCompile project(':mysql') - testCompile project(':postgresql') + testImplementation project(':testcontainers-selenium') + testImplementation project(':testcontainers-mysql') + testImplementation project(':testcontainers-postgresql') - testCompile 'com.zaxxer:HikariCP:3.4.5' - testCompile 'org.apache.httpcomponents:httpclient:4.5.13' + testImplementation 'com.zaxxer:HikariCP:7.1.0' + testImplementation 'org.apache.httpcomponents:httpclient:4.5.14' - testRuntime 'org.postgresql:postgresql:42.2.18' - testRuntime 'mysql:mysql-connector-java:8.0.22' + 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' - testCompileClasspath 'org.jetbrains:annotations:20.1.0' + testCompileOnly 'org.jetbrains:annotations:26.1.0' +} + +tasks.withType(GroovyCompile) { + sourceCompatibility = '1.8' + targetCompatibility = '1.8' + options.encoding = 'UTF-8' } sourceJar { @@ -26,3 +35,9 @@ 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 index 85b58724000..6c71ac421c6 100644 --- a/modules/spock/src/main/groovy/org/testcontainers/spock/SpockTestDescription.groovy +++ b/modules/spock/src/main/groovy/org/testcontainers/spock/SpockTestDescription.groovy @@ -12,25 +12,25 @@ import org.testcontainers.lifecycle.TestDescription @PackageScope class SpockTestDescription implements TestDescription { - String specName - String featureName + String specName + String featureName - static SpockTestDescription fromTestDescription(IMethodInvocation invocation) { - return new SpockTestDescription([ - specName: invocation.spec.name, - featureName: invocation.feature.name - ]) - } + static SpockTestDescription fromTestDescription(IMethodInvocation invocation) { + return new SpockTestDescription([ + specName: invocation.spec.name, + featureName: invocation.feature.name + ]) + } - @Override - String getTestId() { - return getFilesystemFriendlyName() - } + @Override + String getTestId() { + return getFilesystemFriendlyName() + } - @Override - String getFilesystemFriendlyName() { - return [specName, featureName].collect { - URLEncoder.encode(it, 'UTF-8') - }.join('-') - } + @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 index 7bbd47a4629..98c2223904b 100644 --- a/modules/spock/src/main/groovy/org/testcontainers/spock/Testcontainers.groovy +++ b/modules/spock/src/main/groovy/org/testcontainers/spock/Testcontainers.groovy @@ -3,13 +3,62 @@ 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 { -} \ No newline at end of file + /** + * 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 index 1f66fd8f596..2654408e901 100644 --- a/modules/spock/src/main/groovy/org/testcontainers/spock/TestcontainersExtension.groovy +++ b/modules/spock/src/main/groovy/org/testcontainers/spock/TestcontainersExtension.groovy @@ -7,26 +7,39 @@ import org.spockframework.runtime.model.SpecInfo class TestcontainersExtension extends AbstractAnnotationDrivenExtension { - @Override - void visitSpecAnnotation(Testcontainers annotation, SpecInfo spec) { - 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) - } - } - + 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 index 16be002aeb1..cbef0a87bb2 100644 --- a/modules/spock/src/main/groovy/org/testcontainers/spock/TestcontainersMethodInterceptor.groovy +++ b/modules/spock/src/main/groovy/org/testcontainers/spock/TestcontainersMethodInterceptor.groovy @@ -4,6 +4,7 @@ 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 @@ -11,116 +12,148 @@ 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 compose = findAllComposeContainers(true) - startComposeContainers(compose, invocation) - - invocation.proceed() - } - - @Override - void interceptCleanupSpecMethod(IMethodInvocation invocation) throws Throwable { - def containers = findAllContainers(true) - stopContainers(containers, 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 compose = findAllComposeContainers(false) - startComposeContainers(compose, invocation) - - invocation.proceed() - } - - - @Override - void interceptCleanupMethod(IMethodInvocation invocation) throws Throwable { - def containers = findAllContainers(false) - stopContainers(containers, 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 findAllComposeContainers(boolean shared) { - spec.allFields.findAll { FieldInfo f -> - DockerComposeContainer.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 startComposeContainers(List compose, IMethodInvocation invocation) { - compose.each { FieldInfo f -> - DockerComposeContainer c = f.readValue(invocation.instance) as DockerComposeContainer - c.start() - } - } - - private static void stopComposeContainers(List compose, IMethodInvocation invocation) { - compose.each { FieldInfo f -> - DockerComposeContainer c = f.readValue(invocation.instance) as DockerComposeContainer - c.stop() - } - } - - - private static GenericContainer readContainerFromField(FieldInfo f, IMethodInvocation invocation) { - f.readValue(invocation.instance) as GenericContainer - } + 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 index b952dd1c5dc..89de5aed88d 100644 --- a/modules/spock/src/test/groovy/org/testcontainers/spock/ComposeContainerIT.groovy +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/ComposeContainerIT.groovy @@ -2,34 +2,36 @@ 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.ComposeContainer import org.testcontainers.containers.wait.strategy.Wait +import org.testcontainers.utility.DockerImageName import spock.lang.Specification @Testcontainers class ComposeContainerIT extends Specification { - DockerComposeContainer composeContainer = new DockerComposeContainer( - new File("src/test/resources/docker-compose.yml")) - .withExposedService("whoami_1", 80, Wait.forHttp("/")) + 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 + String host - int port + int port - def setup() { - host = composeContainer.getServiceHost("whoami_1", 80) - port = composeContainer.getServicePort("whoami_1", 80) - } + 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() + 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")) + 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 - } + 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 index 639b46f5886..2f44408fed6 100644 --- a/modules/spock/src/test/groovy/org/testcontainers/spock/MySqlContainerIT.groovy +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/MySqlContainerIT.groovy @@ -14,13 +14,11 @@ import spock.lang.Specification @Testcontainers class MySqlContainerIT extends Specification { - @Shared - MySQLContainer mySQLContainer = new MySQLContainer(SpockTestImages.MYSQL_IMAGE) - - def "dummy test"() { - expect: - mySQLContainer.isRunning() - } - + @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 index a886d552732..4b28dbef5c2 100644 --- a/modules/spock/src/test/groovy/org/testcontainers/spock/PostgresContainerIT.groovy +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/PostgresContainerIT.groovy @@ -13,34 +13,33 @@ import java.sql.Statement @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() - } - + @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 index dd2ce9f5b3c..dc71687011f 100644 --- a/modules/spock/src/test/groovy/org/testcontainers/spock/SharedComposeContainerIT.groovy +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/SharedComposeContainerIT.groovy @@ -2,36 +2,38 @@ 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.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 - DockerComposeContainer composeContainer = new DockerComposeContainer( - new File("src/test/resources/docker-compose.yml")) - .withExposedService("whoami_1", 80, Wait.forHttp("/")) + @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 + String host - int port + int port - def setup() { - host = composeContainer.getServiceHost("whoami_1", 80) - port = composeContainer.getServicePort("whoami_1", 80) - } + 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() + 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")) + 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 - } + 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 index 9698e7d433a..ce16e963548 100644 --- a/modules/spock/src/test/groovy/org/testcontainers/spock/SpockTestImages.groovy +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/SpockTestImages.groovy @@ -3,8 +3,8 @@ package org.testcontainers.spock import org.testcontainers.utility.DockerImageName interface SpockTestImages { - DockerImageName MYSQL_IMAGE = DockerImageName.parse("mysql:5.7.22") - 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.5") + 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 index 7fb37446ca5..567998281a7 100644 --- a/modules/spock/src/test/groovy/org/testcontainers/spock/TestHierarchyIT.groovy +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/TestHierarchyIT.groovy @@ -4,22 +4,20 @@ import org.testcontainers.containers.PostgreSQLContainer import spock.lang.Shared /** - * This test verifies that integration tests can subclass each other + * This test verifies that integration tests can subclass each other. + * Also verifies that the @Testcontainers annotation is inherited. */ -@Testcontainers 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() - - } + @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 index eb628fd2f33..7502d097008 100644 --- a/modules/spock/src/test/groovy/org/testcontainers/spock/TestLifecycleAwareContainerMock.java +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/TestLifecycleAwareContainerMock.java @@ -8,20 +8,22 @@ import java.util.List; import java.util.Optional; -import static org.testcontainers.spock.SpockTestImages.TINY_IMAGE; - -public class TestLifecycleAwareContainerMock extends GenericContainer implements TestLifecycleAware { +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(TINY_IMAGE); + super(SpockTestImages.TINY_IMAGE); } @Override diff --git a/modules/spock/src/test/groovy/org/testcontainers/spock/TestLifecycleAwareIT.groovy b/modules/spock/src/test/groovy/org/testcontainers/spock/TestLifecycleAwareIT.groovy index 18f7517ea3f..fae834ea422 100644 --- a/modules/spock/src/test/groovy/org/testcontainers/spock/TestLifecycleAwareIT.groovy +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/TestLifecycleAwareIT.groovy @@ -7,12 +7,12 @@ 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: + @Unroll("When failing test is #fails, afterTest receives '#filesystemFriendlyNames' and throwable starting with message '#errorMessageStartsWith'") + def "lifecycle awareness"() { + given: - @Language("groovy") - String myTest = """ + @Language("groovy") + String myTest = """ import org.testcontainers.spock.Testcontainers import org.testcontainers.containers.GenericContainer import spock.lang.Specification @@ -28,28 +28,27 @@ class TestLifecycleAwareIT extends Specification { } } """ - 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:" - } - + 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 index 81ed0eea724..ebbb1de2474 100644 --- a/modules/spock/src/test/groovy/org/testcontainers/spock/TestcontainersRestartBetweenTestsIT.groovy +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/TestcontainersRestartBetweenTestsIT.groovy @@ -9,23 +9,22 @@ import spock.lang.Stepwise @Testcontainers class TestcontainersRestartBetweenTestsIT extends Specification { - GenericContainer genericContainer = new GenericContainer(SpockTestImages.HTTPD_IMAGE) - .withExposedPorts(80) + GenericContainer genericContainer = new GenericContainer(SpockTestImages.HTTPD_IMAGE) + .withExposedPorts(80) - @Shared - String lastContainerId + @Shared + String lastContainerId - def "retrieving first id"() { - when: - lastContainerId = genericContainer.containerId + def "retrieving first id"() { + when: + lastContainerId = genericContainer.containerId - then: - true - } - - def "containers is recreated between tests"() { - expect: - genericContainer.containerId != lastContainerId - } + 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 index 23b761fcc38..dd2f8443772 100644 --- a/modules/spock/src/test/groovy/org/testcontainers/spock/TestcontainersSharedContainerIT.groovy +++ b/modules/spock/src/test/groovy/org/testcontainers/spock/TestcontainersSharedContainerIT.groovy @@ -13,35 +13,34 @@ import spock.lang.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 - } - + @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/logback-test.xml b/modules/spock/src/test/resources/logback-test.xml index 535e406fc13..83ef7a1a3ef 100644 --- a/modules/spock/src/test/resources/logback-test.xml +++ b/modules/spock/src/test/resources/logback-test.xml @@ -12,5 +12,5 @@ - + 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 index 47221d68e40..3e3a63a1fca 100644 --- a/modules/toxiproxy/build.gradle +++ b/modules/toxiproxy/build.gradle @@ -1,11 +1,8 @@ description = "Testcontainers :: Toxiproxy" dependencies { - compile project(':testcontainers') - compile 'eu.rekawek.toxiproxy:toxiproxy-java:2.1.4' + api project(':testcontainers') + api 'eu.rekawek.toxiproxy:toxiproxy-java:2.1.11' - testCompile 'redis.clients:jedis:3.0.1' - testCompile 'org.rnorth.visible-assertions:visible-assertions:2.1.2' - - testCompile project(':test-support') + 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 index e22cc1fdd55..a3f12516f8d 100644 --- a/modules/toxiproxy/src/main/java/org/testcontainers/containers/ToxiproxyContainer.java +++ b/modules/toxiproxy/src/main/java/org/testcontainers/containers/ToxiproxyContainer.java @@ -17,22 +17,41 @@ import java.util.concurrent.atomic.AtomicInteger; /** - * Container for resiliency testing using Toxiproxy. + * 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 use {@link #ToxiproxyContainer(DockerImageName)} instead */ @Deprecated public ToxiproxyContainer() { @@ -45,8 +64,7 @@ public ToxiproxyContainer(String dockerImageName) { public ToxiproxyContainer(final DockerImageName dockerImageName) { super(dockerImageName); - - dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, GHCR_IMAGE_NAME); addExposedPorts(TOXIPROXY_CONTROL_PORT); setWaitStrategy(new HttpWaitStrategy().forPath("/version").forPort(TOXIPROXY_CONTROL_PORT)); @@ -72,13 +90,15 @@ public int getControlPort() { /** * Obtain a {@link ContainerProxy} instance for target container that is managed by Testcontainers. The target - * container should be routable from this from this {@link ToxiproxyContainer} instance (e.g. on the same + * 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); } @@ -95,46 +115,62 @@ public ContainerProxy getProxy(GenericContainer container, int port) { * @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"); + 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); } - - 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; + @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; + @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; + @Getter + private final int originalProxyPort; + private boolean isCurrentlyCut; public String getName() { 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/containers/ToxiproxyTest.java b/modules/toxiproxy/src/test/java/org/testcontainers/containers/ToxiproxyTest.java deleted file mode 100644 index 3177fb2e8e8..00000000000 --- a/modules/toxiproxy/src/test/java/org/testcontainers/containers/ToxiproxyTest.java +++ /dev/null @@ -1,180 +0,0 @@ -package org.testcontainers.containers; - -import eu.rekawek.toxiproxy.model.ToxicDirection; -import org.junit.Rule; -import org.junit.Test; -import org.testcontainers.utility.DockerImageName; -import redis.clients.jedis.Jedis; -import redis.clients.jedis.exceptions.JedisConnectionException; - -import java.io.IOException; -import java.time.Duration; - -import static java.lang.String.format; -import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; -import static org.rnorth.visibleassertions.VisibleAssertions.assertThrows; -import static org.rnorth.visibleassertions.VisibleAssertions.assertTrue; - -public class ToxiproxyTest { - - private static final Duration JEDIS_TIMEOUT = Duration.ofSeconds(10); - - // creatingProxy { - // An alias that can be used to resolve the Toxiproxy container by name in the network it is connected to. - // It can be used as a hostname of the Toxiproxy container by other containers in the same network. - private static final String TOXIPROXY_NETWORK_ALIAS = "toxiproxy"; - - // Create a common docker network so that containers can communicate - @Rule - public Network network = Network.newNetwork(); - - private static final DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:5.0.4"); - // The target container - this could be anything - @Rule - public GenericContainer redis = new GenericContainer<>(REDIS_IMAGE) - .withExposedPorts(6379) - .withNetwork(network); - - private static final DockerImageName TOXIPROXY_IMAGE = DockerImageName.parse("shopify/toxiproxy:2.1.0"); - // Toxiproxy container, which will be used as a TCP proxy - @Rule - public ToxiproxyContainer toxiproxy = new ToxiproxyContainer(TOXIPROXY_IMAGE) - .withNetwork(network) - .withNetworkAliases(TOXIPROXY_NETWORK_ALIAS); - // } - - @Test - public void testDirect() { - final Jedis jedis = createJedis(redis.getHost(), redis.getFirstMappedPort()); - jedis.set("somekey", "somevalue"); - - final String s = jedis.get("somekey"); - assertEquals("direct access to the container works OK", "somevalue", s); - } - - @Test - public void testLatencyViaProxy() throws IOException { - // obtainProxyObject { - final ToxiproxyContainer.ContainerProxy proxy = toxiproxy.getProxy(redis, 6379); - // } - - // obtainProxiedHostAndPortForHostMachine { - final String ipAddressViaToxiproxy = proxy.getContainerIpAddress(); - final int portViaToxiproxy = proxy.getProxyPort(); - // } - - final Jedis jedis = createJedis(ipAddressViaToxiproxy, portViaToxiproxy); - jedis.set("somekey", "somevalue"); - - checkCallWithLatency(jedis, "without interference", 0, 250); - - // addingLatency { - proxy.toxics() - .latency("latency", ToxicDirection.DOWNSTREAM, 1_100) - .setJitter(100); - // from now on the connection latency should be from 1000-1200 ms. - // } - - checkCallWithLatency(jedis, "with interference", 1_000, 1_500); - } - - @Test - public void testConnectionCut() { - final ToxiproxyContainer.ContainerProxy proxy = toxiproxy.getProxy(redis, 6379); - final Jedis jedis = createJedis(proxy.getContainerIpAddress(), proxy.getProxyPort()); - jedis.set("somekey", "somevalue"); - - assertEquals("access to the container works OK before cutting the connection", "somevalue", jedis.get("somekey")); - - // disableProxy { - proxy.setConnectionCut(true); - - // for example, expect failure when the connection is cut - assertThrows("calls fail when the connection is cut", - JedisConnectionException.class, () -> { - jedis.get("somekey"); - }); - - proxy.setConnectionCut(false); - - // and with the connection re-established, expect success - assertEquals("access to the container works OK after re-establishing the connection", "somevalue", jedis.get("somekey")); - // } - } - - @Test - public void testMultipleProxiesCanBeCreated() { - try (GenericContainer secondRedis = new GenericContainer<>(REDIS_IMAGE) - .withExposedPorts(6379) - .withNetwork(network)) { - - secondRedis.start(); - - final ToxiproxyContainer.ContainerProxy firstProxy = toxiproxy.getProxy(redis, 6379); - final ToxiproxyContainer.ContainerProxy secondProxy = toxiproxy.getProxy(secondRedis, 6379); - - final Jedis firstJedis = createJedis(firstProxy.getContainerIpAddress(), firstProxy.getProxyPort()); - final Jedis secondJedis = createJedis(secondProxy.getContainerIpAddress(), secondProxy.getProxyPort()); - - firstJedis.set("somekey", "somevalue"); - secondJedis.set("somekey", "somevalue"); - - firstProxy.setConnectionCut(true); - - assertThrows("calls fail when the connection is cut, for only the relevant proxy", - JedisConnectionException.class, () -> { - firstJedis.get("somekey"); - }); - - assertEquals("access via a different proxy is OK", "somevalue", secondJedis.get("somekey")); - } - } - - @Test - public void testOriginalAndMappedPorts() { - final ToxiproxyContainer.ContainerProxy proxy = toxiproxy.getProxy("hostname", 7070); - // obtainProxiedHostAndPortForDifferentContainer { - final String hostViaToxiproxy = TOXIPROXY_NETWORK_ALIAS; - final int portViaToxiproxy = proxy.getOriginalProxyPort(); - // } - assertEquals("host is correct", TOXIPROXY_NETWORK_ALIAS, hostViaToxiproxy); - assertEquals("original port is correct", 8666, portViaToxiproxy); - - final ToxiproxyContainer.ContainerProxy proxy1 = toxiproxy.getProxy("hostname1", 8080); - assertEquals("original port is correct", 8667, proxy1.getOriginalProxyPort()); - assertEquals("mapped port is correct", toxiproxy.getMappedPort(proxy1.getOriginalProxyPort()), proxy1.getProxyPort()); - - final ToxiproxyContainer.ContainerProxy proxy2 = toxiproxy.getProxy("hostname2", 9090); - assertEquals("original port is correct", 8668, proxy2.getOriginalProxyPort()); - assertEquals("mapped port is correct", toxiproxy.getMappedPort(proxy2.getOriginalProxyPort()), proxy2.getProxyPort()); - } - - @Test - public void testProxyName() { - final ToxiproxyContainer.ContainerProxy proxy = toxiproxy.getProxy("hostname", 7070); - - assertEquals("proxy name is hostname and port", "hostname:7070", proxy.getName()); - } - - @Test - public void testControlPort() { - final int controlPort = toxiproxy.getControlPort(); - - assertEquals("control port is mapped from port 8474", toxiproxy.getMappedPort(8474), controlPort); - } - - private void checkCallWithLatency(Jedis jedis, final String description, int expectedMinLatency, long expectedMaxLatency) { - final long start = System.currentTimeMillis(); - String s = jedis.get("somekey"); - final long end = System.currentTimeMillis(); - final long duration = end - start; - - assertEquals(format("access to the container %s works OK", description), "somevalue", s); - assertTrue(format("%s there is at least %dms latency", description, expectedMinLatency), duration >= expectedMinLatency); - assertTrue(format("%s there is no more than %dms latency", description, expectedMaxLatency), duration < expectedMaxLatency); - } - - 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/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/build.gradle b/modules/vault/build.gradle index e75d1121131..bbf7929c4e7 100644 --- a/modules/vault/build.gradle +++ b/modules/vault/build.gradle @@ -1,10 +1,8 @@ description = "Testcontainers :: Vault" dependencies { - compile project(':testcontainers') - - testCompile 'com.bettercloud:vault-java-driver:5.1.0' - testCompile 'io.rest-assured:rest-assured:4.3.3' - testCompile 'org.assertj:assertj-core:3.18.1' + 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 index 37e78a14f95..f29595b7ebb 100644 --- a/modules/vault/src/main/java/org/testcontainers/vault/VaultContainer.java +++ b/modules/vault/src/main/java/org/testcontainers/vault/VaultContainer.java @@ -1,6 +1,7 @@ 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; @@ -14,30 +15,31 @@ import java.util.function.Consumer; import java.util.stream.Collectors; -import static com.github.dockerjava.api.model.Capability.IPC_LOCK; - - /** - * GenericContainer subclass for Vault specific configuration and features. The main feature is the - * withSecretInVault method, where users can specify which secrets to be pre-loaded into Vault for - * their specific test scenario. + * Testcontainers implementation for Vault. + *

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

- * Other helpful features include the withVaultPort, and withVaultToken methods for convenience. + * Exposure ports: 8200 */ public class VaultContainer> extends GenericContainer { - private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("vault"); + 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 use {@link #VaultContainer(DockerImageName)} instead */ @Deprecated public VaultContainer() { @@ -50,17 +52,20 @@ public VaultContainer(String dockerImageName) { public VaultContainer(final DockerImageName dockerImageName) { super(dockerImageName); - - dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + 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(IPC_LOCK)); + 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(); @@ -72,7 +77,12 @@ private void addSecrets() { 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()); + logger() + .error( + "Failed to add these secrets {} into Vault via exec command. Exception message: {}", + secretsMap, + e.getMessage() + ); } } } @@ -83,22 +93,34 @@ private String[] buildExecCommand(Map> map) { stringBuilder.append(" && vault kv put " + path); secrets.forEach(item -> stringBuilder.append(" " + item)); }); - return new String[]{"/bin/sh", "-c", stringBuilder.toString().substring(4)}; + 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(" && ")); + String commands = initCommands + .stream() + .map(command -> "vault " + command) + .collect(Collectors.joining(" && ")); try { - ExecResult execResult = this.execInContainer(new String[]{"/bin/sh", "-c", commands}); + 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()); + 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()); + logger() + .error( + "Failed to execute these init commands {}. Exception message: {}", + initCommands, + e.getMessage() + ); } } } @@ -134,7 +156,9 @@ public SELF withVaultPort(int port) { * * @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); } @@ -147,10 +171,12 @@ public SELF withLogLevel(VaultLogLevel level) { * {@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 specifed path + * @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); @@ -166,8 +192,8 @@ public SELF withSecretInVault(String path, String firstSecret, String... remaini /** * Run initialization commands using the vault cli. - * - * Useful for enableing more secret engines like: + *

+ * Useful for enabling more secret engines like: *

      *     .withInitCommand("secrets enable pki")
      *     .withInitCommand("secrets enable transit")
diff --git a/modules/vault/src/main/java/org/testcontainers/vault/VaultLogLevel.java b/modules/vault/src/main/java/org/testcontainers/vault/VaultLogLevel.java
index 58b22f975ff..e5f0db784a8 100644
--- a/modules/vault/src/main/java/org/testcontainers/vault/VaultLogLevel.java
+++ b/modules/vault/src/main/java/org/testcontainers/vault/VaultLogLevel.java
@@ -4,7 +4,11 @@
  * Vault preset of logging levels.
  */
 public enum VaultLogLevel {
-    Trace("trace"), Debug("debug"), Info("info"), Warn("warn"), Error("err");
+    Trace("trace"),
+    Debug("debug"),
+    Info("info"),
+    Warn("warn"),
+    Error("err");
 
     public final String 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
index 4a1a800da17..92f4a6632b9 100644
--- a/modules/vault/src/test/java/org/testcontainers/vault/VaultClientTest.java
+++ b/modules/vault/src/test/java/org/testcontainers/vault/VaultClientTest.java
@@ -4,25 +4,20 @@
 import com.bettercloud.vault.VaultConfig;
 import com.bettercloud.vault.VaultException;
 import com.bettercloud.vault.response.LogicalResponse;
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
 
 import java.util.HashMap;
 import java.util.Map;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.testcontainers.vault.VaultTestImages.VAULT_IMAGE;
 
-public class VaultClientTest {
+class VaultClientTest {
 
     private static final String VAULT_TOKEN = "my-root-token";
 
     @Test
-    public void writeAndReadMultipleValues() throws VaultException {
-        try (
-            VaultContainer vaultContainer = new VaultContainer<>(VAULT_IMAGE)
-                    .withVaultToken(VAULT_TOKEN)
-        ) {
-
+    void writeAndReadMultipleValues() throws VaultException {
+        try (VaultContainer vaultContainer = new VaultContainer<>("vault:1.1.3").withVaultToken(VAULT_TOKEN)) {
             vaultContainer.start();
 
             final VaultConfig config = new VaultConfig()
@@ -37,22 +32,14 @@ public void writeAndReadMultipleValues() throws VaultException {
             secrets.put("other_value", "another world");
 
             // Write operation
-            final LogicalResponse writeResponse = vault.logical()
-                .write("secret/hello", secrets);
+            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");
+            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
index 9dee4c21416..cc2d8b37d73 100644
--- a/modules/vault/src/test/java/org/testcontainers/vault/VaultContainerTest.java
+++ b/modules/vault/src/test/java/org/testcontainers/vault/VaultContainerTest.java
@@ -1,89 +1,170 @@
 package org.testcontainers.vault;
 
-import org.junit.ClassRule;
-import org.junit.Test;
+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.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
 
 import static io.restassured.RestAssured.given;
-import static org.hamcrest.CoreMatchers.*;
-import static org.junit.Assert.assertThat;
+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 and over HTTP.
+ * the secrets were added correctly by reading from Vault with the CLI, over HTTP and over Client Library.
  */
-public class VaultContainerTest {
+class VaultContainerTest {
 
     private static final String VAULT_TOKEN = "my-root-token";
 
-    @ClassRule
-    public static VaultContainer vaultContainer = new VaultContainer<>(VaultTestImages.VAULT_IMAGE)
+    // vaultContainer {
+    public static VaultContainer vaultContainer = new VaultContainer<>("hashicorp/vault:1.13")
         .withVaultToken(VAULT_TOKEN)
-        .withSecretInVault("secret/testing1", "top_secret=password123")
-        .withSecretInVault("secret/testing2",
-            "secret_one=password1",
-            "secret_two=password2",
-            "secret_three=password3",
-            "secret_three=password3",
-            "secret_four=password4")
-        .withInitCommand("secrets enable transit", "write -f transit/keys/my-key");
+        .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
-    public void readFirstSecretPathWithCli() throws IOException, InterruptedException {
-        GenericContainer.ExecResult result = vaultContainer.execInContainer("vault", "kv", "get", "-format=json", "secret/testing1");
-        final String output = result.getStdout().replaceAll("\\r?\\n", "");
-        assertThat(output, containsString("password123"));
+    void readFirstSecretPathWithCli() throws Exception {
+        GenericContainer.ExecResult result = vaultContainer.execInContainer(
+            "vault",
+            "kv",
+            "get",
+            "-format=json",
+            "secret/testing1"
+        );
+        assertThat(result.getStdout()).contains("password123");
     }
 
     @Test
-    public void readSecondSecretPathWithCli() throws IOException, InterruptedException {
-        GenericContainer.ExecResult result = vaultContainer.execInContainer("vault", "kv", "get", "-format=json", "secret/testing2");
+    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, containsString("password1"));
-        assertThat(output, containsString("password2"));
-        assertThat(output, containsString("password3"));
-        assertThat(output, containsString("password4"));
+        assertThat(output).contains("password1");
+        assertThat(output).contains("password2");
+        assertThat(output).contains("password3");
+        assertThat(output).contains("password4");
     }
 
     @Test
-    public void readFirstSecretPathOverHttpApi() throws InterruptedException {
-        given().
-            header("X-Vault-Token", VAULT_TOKEN).
-            when().
-            get("http://" + getHostAndPort() + "/v1/secret/data/testing1").
-            then().
-            assertThat().body("data.data.top_secret", equalTo("password123"));
+    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
-    public void readSecondSecretPathOverHttpApi() throws InterruptedException {
-        given().
-            header("X-Vault-Token", VAULT_TOKEN).
-            when().
-            get("http://" + getHostAndPort() + "/v1/secret/data/testing2").
-            then().
-            assertThat().body("data.data.secret_one", containsString("password1")).
-            assertThat().body("data.data.secret_two", containsString("password2")).
-            assertThat().body("data.data.secret_three", hasItem("password3")).
-            assertThat().body("data.data.secret_four", containsString("password4"));
+    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
-    public void readTransitKeyOverHttpApi() throws InterruptedException {
-        given().
-            header("X-Vault-Token", VAULT_TOKEN).
-            when().
-            get("http://" + getHostAndPort() + "/v1/transit/keys/my-key").
-            then().
-            assertThat().body("data.name", equalTo("my-key"));
+    // 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");
     }
 
-    private String getHostAndPort() {
-        return vaultContainer.getHost() + ":" + vaultContainer.getMappedPort(8200);
+    // }
+
+    @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/java/org/testcontainers/vault/VaultTestImages.java b/modules/vault/src/test/java/org/testcontainers/vault/VaultTestImages.java
deleted file mode 100644
index 9afecdcffb9..00000000000
--- a/modules/vault/src/test/java/org/testcontainers/vault/VaultTestImages.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package org.testcontainers.vault;
-
-import org.testcontainers.utility.DockerImageName;
-
-public interface VaultTestImages {
-    DockerImageName VAULT_IMAGE = DockerImageName.parse("vault:1.1.3");
-}
diff --git a/modules/vault/src/test/resources/logback-test.xml b/modules/vault/src/test/resources/logback-test.xml
index 535e406fc13..83ef7a1a3ef 100644
--- a/modules/vault/src/test/resources/logback-test.xml
+++ b/modules/vault/src/test/resources/logback-test.xml
@@ -12,5 +12,5 @@
         
     
 
-    
+    
 
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/requirements.txt b/requirements.txt index f962b36a44f..f77d057b599 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ -mkdocs==1.0.4 -mkdocs-codeinclude-plugin==0.1.0 -mkdocs-material==4.6.0 -mkdocs-markdownextradata-plugin==0.1.1 -markdown>=3.1,<3.2 +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 index 475ba515c04..cc1923a40b1 100644 --- a/runtime.txt +++ b/runtime.txt @@ -1 +1 @@ -3.7 +3.8 diff --git a/settings.gradle b/settings.gradle index 849a1ade06b..374dbbb00dc 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,13 +5,15 @@ buildscript { } } dependencies { - classpath "gradle.plugin.ch.myniva.gradle:s3-build-cache:0.10.0" - classpath "com.gradle.enterprise:com.gradle.enterprise.gradle.plugin:3.2.1" + 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: 'ch.myniva.s3-build-cache' -apply plugin: 'com.gradle.enterprise' +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' @@ -21,42 +23,38 @@ include "testcontainers" project(':testcontainers').projectDir = "$rootDir/core" as File file('modules').eachDir { dir -> - include dir.name - project(":${dir.name}").projectDir = dir + include "testcontainers-${dir.name}" + project(":testcontainers-${dir.name}").projectDir = dir } -include ':docs:examples:junit4:generic' -include ':docs:examples:junit4:redis' include ':docs:examples:junit5:redis' include ':docs:examples:spock:redis' include 'test-support' -ext.isMasterBuild = false || - (System.getenv("GITHUB_REF") == "refs/heads/master") || - (System.getenv("BUILD_SOURCEBRANCHNAME") == "master") +ext.isCI = System.getenv("CI") != null buildCache { local { - enabled = !isMasterBuild + enabled = !isCI } - - remote(ch.myniva.gradle.caching.s3.AwsS3BuildCache) { - endpoint = 'fra1.digitaloceanspaces.com' - region = 'fra1' - bucket = 'testcontainers' - path = 'cache' - reducedRedundancy = false - push = isMasterBuild && !System.getenv("READ_ONLY_REMOTE_GRADLE_CACHE") - headers = [ - 'x-amz-acl': 'public-read' - ] + 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 } } -gradleEnterprise { +develocity { + server = "https://community.develocity.cloud" + projectId = "testcontainers" buildScan { - termsOfServiceUrl = 'https://gradle.com/terms-of-service' - termsOfServiceAgree = 'yes' + publishing.onlyIf { + it.authenticated + } + uploadInBackground = !isCI + obfuscation { + ipAddresses { it.collect { "0.0.0.0" } } + } } } 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 index 4c6fdd2bed1..56fcbd98a92 100644 --- a/test-support/build.gradle +++ b/test-support/build.gradle @@ -1,4 +1,5 @@ dependencies { - implementation 'junit:junit:4.12' - implementation 'org.slf4j:slf4j-api:1.7.30' + 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 index cc5f6f947fa..83e18bfa161 100644 --- a/test-support/src/main/java/org/testcontainers/testsupport/Flaky.java +++ b/test-support/src/main/java/org/testcontainers/testsupport/Flaky.java @@ -1,19 +1,17 @@ package org.testcontainers.testsupport; +import java.lang.annotation.ElementType; import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import static java.lang.annotation.ElementType.METHOD; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - /** * Annotation for test methods that should be retried in the event of failure. See {@link FlakyTestJUnit4RetryRule} for * more details. */ -@Retention(RUNTIME) -@Target({METHOD}) +@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. diff --git a/test-support/src/main/java/org/testcontainers/testsupport/FlakyTestJUnit4RetryRule.java b/test-support/src/main/java/org/testcontainers/testsupport/FlakyTestJUnit4RetryRule.java index aa1a3ed3c5f..759e23ebbca 100644 --- a/test-support/src/main/java/org/testcontainers/testsupport/FlakyTestJUnit4RetryRule.java +++ b/test-support/src/main/java/org/testcontainers/testsupport/FlakyTestJUnit4RetryRule.java @@ -31,7 +31,6 @@ public class FlakyTestJUnit4RetryRule implements TestRule { @Override public Statement apply(Statement base, Description description) { - final Flaky annotation = description.getAnnotation(Flaky.class); if (annotation == null) { @@ -53,11 +52,13 @@ public Statement apply(Statement base, Description description) { 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"); + 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) ) { + if (LocalDate.now().isBefore(reviewDate)) { return new RetryingStatement(base, description, maxTries); } else { return base; @@ -65,8 +66,11 @@ public Statement apply(Statement base, Description description) { } 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) { @@ -77,7 +81,6 @@ private static class RetryingStatement extends Statement { @Override public void evaluate() { - int attempts = 0; final List causes = new ArrayList<>(); @@ -93,7 +96,8 @@ public void evaluate() { throw new IllegalStateException( "@Flaky-annotated test failed despite retries.", - new MultipleFailureException(causes)); + 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 index 07b0ffe1fa8..60a311922d0 100644 --- a/test-support/src/test/java/org/testcontainers/testsupport/FlakyRuleTest.java +++ b/test-support/src/test/java/org/testcontainers/testsupport/FlakyRuleTest.java @@ -6,17 +6,21 @@ import java.lang.annotation.Annotation; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; +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(); @@ -28,10 +32,10 @@ public void testIgnoresMethodWithoutAnnotation() throws Throwable { try { rule.apply(statement, description).evaluate(); fail("Should not reach here"); - } catch (Exception ignored) { - - } - assertEquals("The statement should only be invoked once, even if it throws", 1, statement.invocationCount); + } catch (Exception ignored) {} + assertThat(statement.invocationCount) + .as("The statement should only be invoked once, even if it throws") + .isEqualTo(1); } @Test @@ -42,10 +46,8 @@ public void testRetriesMethodWithAnnotationUntilFailure() throws Throwable { try { rule.apply(statement, description).evaluate(); fail("Should not reach here"); - } catch (Exception ignored) { - - } - assertEquals("The statement should be invoked three times", 3, statement.invocationCount); + } catch (Exception ignored) {} + assertThat(statement.invocationCount).as("The statement should be invoked three times").isEqualTo(3); } @Test @@ -56,10 +58,8 @@ public void testCustomRetryCount() throws Throwable { try { rule.apply(statement, description).evaluate(); fail("Should not reach here"); - } catch (Exception ignored) { - - } - assertEquals("The statement should be invoked ten times", 10, statement.invocationCount); + } catch (Exception ignored) {} + assertThat(statement.invocationCount).as("The statement should be invoked ten times").isEqualTo(10); } @Test @@ -70,7 +70,9 @@ public void testRetriesMethodWithAnnotationUntilSuccess() throws Throwable { rule.apply(statement, description).evaluate(); - assertEquals("The statement should be invoked three times, and succeed the third time", 3, statement.invocationCount); + assertThat(statement.invocationCount) + .as("The statement should be invoked three times, and succeed the third time") + .isEqualTo(3); } @Test @@ -81,7 +83,7 @@ public void testDoesNotRetryMethodWithAnnotationIfNotThrowing() throws Throwable rule.apply(statement, description).evaluate(); - assertEquals("The statement should be invoked once", 1, statement.invocationCount); + assertThat(statement.invocationCount).as("The statement should be invoked once").isEqualTo(1); } @Test @@ -91,10 +93,10 @@ public void testTreatsExpiredAnnotationAsNoAnnotation() throws Throwable { try { rule.apply(statement, description).evaluate(); fail("Should not reach here"); - } catch (Exception ignored) { - - } - assertEquals("The statement should only be invoked once, even if it throws", 1, statement.invocationCount); + } catch (Exception ignored) {} + assertThat(statement.invocationCount) + .as("The statement should only be invoked once, even if it throws") + .isEqualTo(1); } @Test @@ -104,10 +106,10 @@ public void testThrowsOnInvalidDateFormat() throws Throwable { try { rule.apply(statement, description).evaluate(); fail("Should not reach here"); - } catch (IllegalArgumentException ignored) { - - } - assertEquals("The statement should not be invoked if the annotation is invalid", 0, statement.invocationCount); + } catch (IllegalArgumentException ignored) {} + assertThat(statement.invocationCount) + .as("The statement should not be invoked if the annotation is invalid") + .isEqualTo(0); } @Test @@ -117,14 +119,18 @@ public void testThrowsOnInvalidGitHubUrl() throws Throwable { try { rule.apply(statement, description).evaluate(); fail("Should not reach here"); - } catch (IllegalArgumentException ignored) { - - } - assertEquals("The statement should not be invoked if the annotation is invalid", 0, statement.invocationCount); + } 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)); + return Description.createTestDescription( + "SomeTestClass", + "someMethod", + newAnnotation(reviewDate, gitHubUrl, DEFAULT_TRIES) + ); } private Description newDescriptionWithoutAnnotation() { @@ -132,15 +138,27 @@ private Description newDescriptionWithoutAnnotation() { } private Description newDescriptionWithAnnotation() { - return Description.createTestDescription("SomeTestClass", "someMethod", newAnnotation(VALID_DATE_IN_FAR_FUTURE, VALID_URL, DEFAULT_TRIES)); + 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)); + 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)); + return Description.createTestDescription( + "SomeTestClass", + "someMethod", + newAnnotation(VALID_DATE_IN_PAST, VALID_URL, DEFAULT_TRIES) + ); } private Flaky newAnnotation(final String reviewDate, String gitHubUrl, int tries) { @@ -174,7 +192,9 @@ private DummyStatement newStatement(int timesToThrow) { } private static class DummyStatement extends Statement { + int shouldThrowTimes = -1; + int invocationCount = 0; @Override