diff --git a/.config/pmd/java/ruleset.xml b/.config/pmd/java/ruleset.xml index b6d43e51..8dde42bc 100644 --- a/.config/pmd/java/ruleset.xml +++ b/.config/pmd/java/ruleset.xml @@ -141,6 +141,7 @@ + diff --git a/.github/actions/docker-image-cache/README.md b/.github/actions/docker-image-cache/README.md new file mode 100644 index 00000000..2e65f24a --- /dev/null +++ b/.github/actions/docker-image-cache/README.md @@ -0,0 +1,7 @@ +# Composite GitHub Actions + +Example usage can be found in [`run-integration-tests.yml`](../../workflows/run-integration-tests.yml) + +## Notes +* [Parallel execution](https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/) is not yet available for composite actions + * Therefore `restore` and `setup-buildx` are currently split as it's recommended that they be implemented in parallel downstream diff --git a/.github/actions/docker-image-cache/restore/README.md b/.github/actions/docker-image-cache/restore/README.md new file mode 100644 index 00000000..5f6ad4c8 --- /dev/null +++ b/.github/actions/docker-image-cache/restore/README.md @@ -0,0 +1 @@ +Restores previously downloaded images so that they don't need to be pulled diff --git a/.github/actions/docker-image-cache/restore/action.yml b/.github/actions/docker-image-cache/restore/action.yml new file mode 100644 index 00000000..528c714e --- /dev/null +++ b/.github/actions/docker-image-cache/restore/action.yml @@ -0,0 +1,47 @@ +name: 'Restore docker image cache' +description: 'Restore and load docker image cache' +# Based on https://github.com/actions/cache/blob/v6/restore/action.yml +inputs: + key: + description: 'An explicit key for restoring the cache' + required: true + restore-keys: + description: 'An ordered multiline string listing the prefix-matched keys, that are used for restoring stale cache if no cache hit occurred for key. Note `cache-hit` returns false in this case.' + required: false + save-preinstalled-images: + description: 'Should pre-installed images be saved (not recommended)' + required: false +outputs: + key: + description: 'Identical to the input - can be used to prevent copy pasting' + value: ${{ inputs.key }} + cache-hit: + description: 'A boolean value to indicate an exact match was found for the primary key' + value: ${{ steps.restore-docker-image-cache.outputs.cache-hit }} +runs: + using: "composite" + steps: + # NOTE 2026-07-09 Parallel execution is not yet available for composite actions + # https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/ + - name: Restore docker image cache + uses: actions/cache/restore@v6 + id: restore-docker-image-cache + with: + path: ~/.cache/docker/ + key: ${{ inputs.key }} + restore-keys: ${{ inputs.restore-keys }} + + - name: Load docker image cache + shell: bash + run: | + if [ "${{ inputs.save-preinstalled-images }}" != true ]; then + echo "Getting preinstalled images" && docker image ls + preinstalled_image_ids=$(docker images -q | sort) + + echo "Storing preinstalled image ids" && echo $preinstalled_image_ids + mkdir -p ~/.cache/docker-preinstalled + printf "%s\n" "${preinstalled_image_ids[@]}" > ~/.cache/docker-preinstalled/ids-${{ github.run_id }}.txt + fi + + echo "Importing cache" + docker load -i ~/.cache/docker/images.tar && echo "Restored docker image cache" && docker image ls || true diff --git a/.github/actions/docker-image-cache/save/README.md b/.github/actions/docker-image-cache/save/README.md new file mode 100644 index 00000000..6eef1df3 --- /dev/null +++ b/.github/actions/docker-image-cache/save/README.md @@ -0,0 +1 @@ +Saves the pulled images into the cache diff --git a/.github/actions/docker-image-cache/save/action.yml b/.github/actions/docker-image-cache/save/action.yml new file mode 100644 index 00000000..de54456d --- /dev/null +++ b/.github/actions/docker-image-cache/save/action.yml @@ -0,0 +1,49 @@ +name: 'Save docker image cache' +description: 'Save the docker image cache' +# Based on https://github.com/actions/cache/blob/v6/restore/action.yml +inputs: + key: + description: 'An explicit key for restoring the cache' + required: true + cache-hit: + description: 'A boolean value to indicate an exact match was found for the primary key' + required: false +runs: + using: "composite" + steps: + - name: Create docker image cache + # Cache can't be saved when it's already present + if: inputs.cache-hit != 'true' + shell: bash + run: | + echo "Images before pruning" && docker image ls + docker image prune -f + echo "Currently available images" && docker image ls + + has_preinstalled_ids=false + if [ -f ~/.cache/docker-preinstalled/ids-${{ github.run_id }}.txt ]; then + echo && echo "Will NOT cache pre-installed images" && cat ~/.cache/docker-preinstalled/ids-${{ github.run_id }}.txt + has_preinstalled_ids=true + fi + + echo && echo "Will NOT cache images" && docker image ls ${CI_CACHE_DOCKER_SAVE_IGNORE_FILTERS:--f="label=org.testcontainers.sessionId"} + + mkdir -p ~/.cache/docker + image_ids_to_save=$(comm -23 <(docker images -q | sort) <(docker image ls -q ${CI_CACHE_DOCKER_SAVE_IGNORE_FILTERS:--f="label=org.testcontainers.sessionId"} | sort)) + if [ "$has_preinstalled_ids" = true ]; then + image_ids_to_save=$(comm -23 <(echo "${image_ids_to_save[@]}") <(cat ~/.cache/docker-preinstalled/ids-${{ github.run_id }}.txt || echo "")) + fi + echo && echo "Saving image ids" && echo $image_ids_to_save + docker save $image_ids_to_save -o ~/.cache/docker/images.tar || true + + ls -lha ~/.cache/docker + if [ "$has_preinstalled_ids" = true ]; then + rm -f ~/.cache/docker-preinstalled/ids-${{ github.run_id }}.txt || true + fi + + - name: Save docker image cache + if: inputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: ~/.cache/docker/ + key: ${{ inputs.key }} diff --git a/.github/actions/docker-image-cache/setup-buildx/README.md b/.github/actions/docker-image-cache/setup-buildx/README.md new file mode 100644 index 00000000..09e54fd5 --- /dev/null +++ b/.github/actions/docker-image-cache/setup-buildx/README.md @@ -0,0 +1 @@ +Set's up the buildx worker and returns [required environment variables](https://docs.docker.com/build/cache/backends/gha/#authentication) for the buildx build. diff --git a/.github/actions/docker-image-cache/setup-buildx/action.yml b/.github/actions/docker-image-cache/setup-buildx/action.yml new file mode 100644 index 00000000..ac66f285 --- /dev/null +++ b/.github/actions/docker-image-cache/setup-buildx/action.yml @@ -0,0 +1,23 @@ +name: 'Setup buildx' +description: 'Setup buildx' +outputs: + ACTIONS_RUNTIME_TOKEN: + value: ${{ steps.actions-vars.outputs.ACTIONS_RUNTIME_TOKEN }} + ACTIONS_RESULTS_URL: + value: ${{ steps.actions-vars.outputs.ACTIONS_RESULTS_URL }} +runs: + using: "composite" + steps: + # These are required for caching + # https://docs.docker.com/build/cache/backends/gha/#authentication + - name: Get Actions Runtime credentials + if: runner.os != 'Windows' + id: actions-vars + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + core.setOutput('ACTIONS_RUNTIME_TOKEN', process.env['ACTIONS_RUNTIME_TOKEN']) + core.setOutput('ACTIONS_RESULTS_URL', process.env['ACTIONS_RESULTS_URL']) + + - uses: docker/setup-buildx-action@v4 + if: runner.os != 'Windows' diff --git a/.github/workflows/broken-links.yml b/.github/workflows/broken-links.yml index fbe05e74..8aeed096 100644 --- a/.github/workflows/broken-links.yml +++ b/.github/workflows/broken-links.yml @@ -19,7 +19,7 @@ jobs: - name: Link Checker id: lychee - uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2 + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2 with: args: "--verbose --no-progress './**/*.md'" fail: false # Don't fail on broken links, create an issue instead diff --git a/.github/workflows/check-build.yml b/.github/workflows/check-build.yml index 3db742c4..127dc248 100644 --- a/.github/workflows/check-build.yml +++ b/.github/workflows/check-build.yml @@ -28,7 +28,7 @@ jobs: timeout-minutes: 30 strategy: matrix: - java: [21, 25] + java: [25] distribution: [temurin] steps: - uses: actions/checkout@v7 @@ -74,7 +74,7 @@ jobs: timeout-minutes: 15 strategy: matrix: - java: [21] + java: [25] distribution: [temurin] steps: - uses: actions/checkout@v7 @@ -110,7 +110,7 @@ jobs: timeout-minutes: 15 strategy: matrix: - java: [21] + java: [25] distribution: [temurin] steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9968f3d5..f1bf26d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: - name: Set up JDK uses: actions/setup-java@v5 with: - java-version: '21' + java-version: '25' distribution: 'temurin' # Try to reuse existing cache from check-build @@ -125,7 +125,7 @@ jobs: uses: actions/setup-java@v5 with: # running setup-java overwrites the settings.xml distribution: 'temurin' - java-version: '21' + java-version: '25' server-id: github-central server-password: PACKAGES_CENTRAL_TOKEN gpg-passphrase: MAVEN_GPG_PASSPHRASE @@ -147,7 +147,7 @@ jobs: uses: actions/setup-java@v5 with: # running setup-java again overwrites the settings.xml distribution: 'temurin' - java-version: '21' + java-version: '25' server-id: sonatype-central-portal server-username: MAVEN_CENTRAL_USERNAME server-password: MAVEN_CENTRAL_TOKEN @@ -182,7 +182,7 @@ jobs: - name: Setup - Java uses: actions/setup-java@v5 with: - java-version: '21' + java-version: '25' distribution: 'temurin' # Try to reuse existing cache from check-build diff --git a/.github/workflows/run-integration-tests.yml b/.github/workflows/run-integration-tests.yml index 479f6bf2..4d8deaa7 100644 --- a/.github/workflows/run-integration-tests.yml +++ b/.github/workflows/run-integration-tests.yml @@ -21,68 +21,178 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - run-integration-tests: + persistence: strategy: fail-fast: false matrix: - project: [persistence-it-hibernate, webapp-it] + project: [persistence-it-hibernate] parallel: [0, 2] pre-start: [false, true] - java: [21] + java: [25] include: - - project: webapp-it - jacoco: true - video: true - project: persistence-it-eclipselink parallel: 2 pre-start: true - java: 21 - name: "run-integration-tests (${{matrix.project}}, ${{matrix.parallel}}, ${{matrix.pre-start}}, ${{matrix.java}})" + java: 25 + name: "persistence (${{matrix.project}}, ${{matrix.parallel}}, ${{matrix.pre-start}}, ${{matrix.java}})" runs-on: ubuntu-latest if: ${{ !(github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/')) }} + timeout-minutes: 10 permissions: contents: read - checks: write # JaCoCo Coverage Report check steps: - uses: actions/checkout@v7 - - name: Set up JDK - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: ${{ matrix.java }} + - parallel: + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ matrix.java }} + + - name: Cache Maven + uses: actions/cache@v6 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-mvn-it-build-persistence-${{ matrix.project }}-${{ hashFiles('**/pom.xml') }} + + - name: Docker Image Cache Restore + id: docker-image-cache-restore + uses: ./.github/actions/docker-image-cache/restore + with: + # This cache could always change however because docker images can be changed remotely if the version is not fixated + # As a compromise it utilizes the hash of the pom files because when the dependencies stay the same + # the images should also be roughly the same + key: it-docker-images-persistence-${{ matrix.project }}-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-it-docker-images-persistence-${{ matrix.project }}-${{ hashFiles('**/pom.xml') }} + ${{ runner.os }}-it-docker-images-persistence-${{ matrix.project }}- + + - name: Setup Buildx + id: docker-image-cache-setup-buildx + uses: ./.github/actions/docker-image-cache/setup-buildx + + - name: Test + run: | + ./mvnw -B test \ + -pl "advanced-demo/integration-tests/${{ matrix.project }}" -am \ + -T2C \ + -P run-it \ + -Dlicense.skip \ + ${{ matrix.pre-start && '-Dtci.infra-pre-start.enabled=1 ' || '' }} \ + ${{ matrix.parallel > 0 && format('-Djunit.jupiter.execution.parallel.enabled=true -Djunit.jupiter.execution.parallel.mode.default=concurrent -Djunit.jupiter.execution.parallel.mode.classes.default=concurrent -Djunit.jupiter.execution.parallel.config.strategy=fixed -Djunit.jupiter.execution.parallel.config.fixed.parallelism=2 -Djunit.jupiter.execution.parallel.config.fixed.max-pool-size={0} ', matrix.parallel) || '' }} + env: + ACTIONS_RUNTIME_TOKEN: ${{ steps.docker-image-cache-setup-buildx.outputs.ACTIONS_RUNTIME_TOKEN }} + ACTIONS_RESULTS_URL: ${{ steps.docker-image-cache-setup-buildx.outputs.ACTIONS_RESULTS_URL }} + + - name: Show images + run: docker image ls - - name: Cache Maven - uses: actions/cache@v6 + - name: Docker Image Cache Save + uses: ./.github/actions/docker-image-cache/save with: - path: ~/.m2/repository - key: ${{ runner.os }}-mvn-it-build-${{ hashFiles('**/pom.xml') }} - restore-keys: | - ${{ runner.os }}-mvn-it-build- - ${{ runner.os }}-mvn-build- + key: ${{ steps.docker-image-cache-restore.outputs.key }} + cache-hit: ${{ steps.docker-image-cache-restore.outputs.cache-hit }} + + selenium: + strategy: + fail-fast: false + matrix: + project: [webapp-it] + parallel: [0, 2] + pre-start: [false, true] + java: [25] + jacoco: [false] + # Dedicated build with JaCoCo + include: + - project: webapp-it + parallel: 2 + pre-start: true + java: 25 + jacoco: true + name: "selenium (${{matrix.project}}, ${{matrix.parallel }}, ${{matrix.pre-start}}, ${{matrix.java}}${{ matrix.jacoco && ', JaCoCo' || '' }})" + runs-on: ubuntu-latest + if: ${{ !(github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/')) }} + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + + - parallel: + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ matrix.java }} + + - name: Cache Maven + uses: actions/cache@v6 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-mvn-it-build-selenium-${{ matrix.project }}-${{ hashFiles('**/pom.xml') }} + + - name: Docker Image Cache Restore + id: docker-image-cache-restore + uses: ./.github/actions/docker-image-cache/restore + with: + # This cache could always change however because docker images can be changed remotely if the version is not fixated + # As a compromise it utilizes the hash of the pom files because when the dependencies stay the same + # the images should also be roughly the same + key: it-docker-images-selenium-${{ matrix.project }}-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-it-docker-images-selenium-${{ matrix.project }}-${{ hashFiles('**/pom.xml') }} + ${{ runner.os }}-it-docker-images-selenium-${{ matrix.project }}- + + - name: Setup Buildx + id: docker-image-cache-setup-buildx + uses: ./.github/actions/docker-image-cache/setup-buildx + + - name: Report CPU details + run: | + cat /proc/cpuinfo | grep -P '^model name' | uniq + cat /proc/cpuinfo | grep -P '^flags' | uniq - name: Test run: | + # Differentiate cache between AVX microcode used by Java (until https://github.com/xdev-software/tci/issues/688 is fixed) + java_useavx=$(java -XX:+PrintFlagsFinal -version 2> /dev/null | awk '/UseAVX/ {print $4}') + echo "Java UseAVX: ${java_useavx}" + ./mvnw -B test \ -pl "advanced-demo/integration-tests/${{ matrix.project }}" -am \ + -T2C \ -P run-it${{ matrix.jacoco && ',jacoco' || '' }} \ - ${{ matrix.pre-start && '-Dinfra-pre-start.enabled=1 ' || '' }} \ + -Dlicense.skip \ + -Dtci.image-build.delete-on-exit=true \ + -Dtci.image-build.cache-from="type=gha,scope=§image" \ + -Dtci.image-build.cache-to="type=gha,mode=max,scope=§image" \ + -Dtci.image-build.tci-demo-webapp.cache-from="type=gha,scope=§image-j_avx${java_useavx}${{ matrix.jacoco && '-jacoco' || '' }}" \ + -Dtci.image-build.tci-demo-webapp.cache-to="type=gha,mode=max,scope=§image-j_avx${java_useavx}${{ matrix.jacoco && '-jacoco' || '' }}" \ + ${{ matrix.pre-start && '-Dtci.infra-pre-start.enabled=1 ' || '' }} \ ${{ matrix.parallel > 0 && format('-Djunit.jupiter.execution.parallel.enabled=true -Djunit.jupiter.execution.parallel.mode.default=concurrent -Djunit.jupiter.execution.parallel.mode.classes.default=concurrent -Djunit.jupiter.execution.parallel.config.strategy=fixed -Djunit.jupiter.execution.parallel.config.fixed.parallelism=2 -Djunit.jupiter.execution.parallel.config.fixed.max-pool-size={0} ', matrix.parallel) || '' }} - - # Replace '/' with '-' - - name: Normalize project name - if: ${{ !cancelled() }} env: - PROJECT: ${{ matrix.project }} - run: echo PROJECT_NORMALIZED=${PROJECT/\//-} >> $GITHUB_ENV + ACTIONS_RUNTIME_TOKEN: ${{ steps.docker-image-cache-setup-buildx.outputs.ACTIONS_RUNTIME_TOKEN }} + ACTIONS_RESULTS_URL: ${{ steps.docker-image-cache-setup-buildx.outputs.ACTIONS_RESULTS_URL }} + + - name: Show images + run: docker image ls + + - name: Docker Image Cache Save + uses: ./.github/actions/docker-image-cache/save + with: + key: ${{ steps.docker-image-cache-restore.outputs.key }} + cache-hit: ${{ steps.docker-image-cache-restore.outputs.cache-hit }} + background: true - name: Upload videos of test failures - if: ${{ matrix.video && failure() }} + if: ${{ failure() }} uses: actions/upload-artifact@v7 with: - name: test-fail-videos-${{ matrix.java }}-${{ env.PROJECT_NORMALIZED }}-${{ matrix.parallel }}-${{ matrix.pre-start }} + name: test-fail-videos-${{ matrix.java }}-${{ matrix.project }}-${{ matrix.parallel }}-${{ matrix.pre-start }} path: advanced-demo/integration-tests/${{ matrix.project }}/target/records if-no-files-found: ignore @@ -96,26 +206,9 @@ jobs: if: ${{ matrix.jacoco && success() }} uses: actions/upload-artifact@v7 with: - name: webapp-jacoco-report-${{ matrix.java }}-${{ env.PROJECT_NORMALIZED }}-${{ matrix.parallel }}-${{ matrix.pre-start }} + name: webapp-jacoco-report-${{ matrix.java }}-${{ matrix.project }}-${{ matrix.parallel }}-${{ matrix.pre-start }} path: advanced-demo/target/site/jacoco-aggregate if-no-files-found: ignore - - name: WebApp JaCoCo Code Coverage Report - if: ${{ matrix.jacoco && success() }} - id: jacoco_reporter - uses: PavanMudigonda/jacoco-reporter@e8b54bfea6a667d1a68624dae8a06ba31670667d # v5.1 - with: - coverage_results_path: advanced-demo/target/site/jacoco-aggregate/jacoco.xml - coverage_report_name: WebApp Coverage (${{matrix.parallel}}, ${{matrix.pre-start}}, ${{matrix.java}}) - coverage_report_title: JaCoCo - github_token: ${{ secrets.GITHUB_TOKEN }} - - - name: Add WebApp JaCoCo report to workflow run summary - if: ${{ matrix.jacoco && success() }} - run: | - echo "| Outcome | Value |" >> $GITHUB_STEP_SUMMARY - echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY - echo "| Code Coverage % | ${{ steps.jacoco_reporter.outputs.coverage_percentage }} |" >> $GITHUB_STEP_SUMMARY - echo "| :heavy_check_mark: Number of Lines Covered | ${{ steps.jacoco_reporter.outputs.covered_lines }} |" >> $GITHUB_STEP_SUMMARY - echo "| :x: Number of Lines Missed | ${{ steps.jacoco_reporter.outputs.missed_lines }} |" >> $GITHUB_STEP_SUMMARY - echo "| Total Number of Lines | ${{ steps.jacoco_reporter.outputs.total_lines }} |" >> $GITHUB_STEP_SUMMARY + - name: Wait for async jobs to finish + wait-all: diff --git a/.github/workflows/run-mailpit-integration-tests.yml b/.github/workflows/run-mailpit-integration-tests.yml new file mode 100644 index 00000000..7278bcc1 --- /dev/null +++ b/.github/workflows/run-mailpit-integration-tests.yml @@ -0,0 +1,68 @@ +name: Run Mailpit integration tests + +on: + workflow_dispatch: + push: + branches: [ develop ] + paths: + - 'mailpit/**' + pull_request: + branches: [ develop ] + paths: + - 'mailpit/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + mailpit: + runs-on: ubuntu-latest + if: ${{ !(github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/')) }} + timeout-minutes: 7 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + + - parallel: + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 25 + + - name: Cache Maven + uses: actions/cache@v6 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-mvn-it-build-mailpit-${{ hashFiles('**/pom.xml') }} + + - name: Docker Image Cache Restore + id: docker-image-cache-restore + uses: ./.github/actions/docker-image-cache/restore + with: + # This cache could always change however because docker images can be changed remotely if the version is not fixated + # As a compromise it utilizes the hash of the pom files because when the dependencies stay the same + # the images should also be roughly the same + key: it-docker-images-mailpit-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-it-docker-images-mailpit-${{ hashFiles('**/pom.xml') }} + ${{ runner.os }}-it-docker-images-mailpit- + + - name: Test + run: | + ./mvnw -B test \ + -pl "mailpit" -am \ + -T2C \ + -P run-it \ + -Dlicense.skip + + - name: Show images + run: docker image ls + + - name: Docker Image Cache Save + uses: ./.github/actions/docker-image-cache/save + with: + key: ${{ steps.docker-image-cache-restore.outputs.key }} + cache-hit: ${{ steps.docker-image-cache-restore.outputs.cache-hit }} diff --git a/.github/workflows/test-deploy.yml b/.github/workflows/test-deploy.yml index 2042eec9..570e1a33 100644 --- a/.github/workflows/test-deploy.yml +++ b/.github/workflows/test-deploy.yml @@ -14,7 +14,7 @@ jobs: uses: actions/setup-java@v5 with: # running setup-java overwrites the settings.xml distribution: 'temurin' - java-version: '21' + java-version: '25' server-id: github-central server-password: PACKAGES_CENTRAL_TOKEN gpg-passphrase: MAVEN_GPG_PASSPHRASE @@ -36,7 +36,7 @@ jobs: uses: actions/setup-java@v5 with: # running setup-java again overwrites the settings.xml distribution: 'temurin' - java-version: '21' + java-version: '25' server-id: sonatype-central-portal server-username: MAVEN_CENTRAL_USERNAME server-password: MAVEN_CENTRAL_TOKEN diff --git a/.idea/checkstyle-idea.xml b/.idea/checkstyle-idea.xml index 3dbbcc7a..b8b753e6 100644 --- a/.idea/checkstyle-idea.xml +++ b/.idea/checkstyle-idea.xml @@ -1,7 +1,7 @@ - 13.5.0 + latest JavaOnlyWithTests true true diff --git a/CHANGELOG.md b/CHANGELOG.md index b428b456..a84d54bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,40 @@ +# 4.0.0 +* Added `image-build` module + * This module utilizes v4 of [testcontainers-advanced-imagebuilder](https://github.com/xdev-software/testcontainers-advanced-imagebuilder) + * It contains some shortcuts that help with common configuration e.g. related to caching (see below) + * Can be configured using environment variables or system properties (see corresponding configuration section for details) +* Added `mailpit` module #455 +* Added dedicated composite actions for caching + * These can be found in `.github/actions/docker-image-cache` + * Currently 3 actions exist: + * `restore` - Restores the previously downloaded images + * `setup-buildx` - Set's up the buildx worker and returns required environment variables + * `save` - Saves the downloaded images +* Added abortable wait strategies + * primarily designed to be used with `FastAbortOnContainerDeathWaitStrategy` + * the strategies can be aborted e.g. when a container died during startup + * a dead container therefore no longer has to wait until the corresponding strategy times out + * Implemented in all applicable modules +* `db-jdbc-spring-orm` & implementations + * No longer use `setPersistenceProviderClassName` because it has no effect + * Performance: Reuse `PersistenceProvider` +* Standardized some configuration properties + * `tci.selenium` + * `recordMode` → `record-mode` + * `recordDir` → `record-dir` + * `vncEnabled` → `vnc-enabled` + * `bidiEnabled` → `bidi-enabled` + * `deactivateCdpIfPossible` → `deactivate-cdp-if-possible` + * `browserConsoleLogLevel` → `min-browser-console-log-level` + * `infra-pre-start` → `tci.infra-pre-start` + * `leak-detection` → `tci.leak-detection` +* Fixed a deadlock during service resolution +* Fixed rate limiting in some places +* Improve container cleanup +* Updated dependencies +* Updated demo +* Updated docs + # 3.4.1 * Remove dedicated code for Java 17 (no longer needed because Java 21 is the minimum) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 181fe66f..987e2843 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,14 +23,14 @@ You should have the following things installed: * Maven (Note that the [Maven Wrapper](https://maven.apache.org/wrapper/) is shipped with the repo) ### Recommended setup -* Install ``IntelliJ`` (Community Edition is sufficient) - * Install the following plugins: - * [Save Actions](https://plugins.jetbrains.com/plugin/22113) - Provides save actions, like running the formatter or adding ``final`` to fields - * [SonarLint](https://plugins.jetbrains.com/plugin/7973-sonarlint) - CodeStyle/CodeAnalysis - * You may consider disabling telemetry in the settings under ``Tools > Sonarlint -> About`` - * [Checkstyle-IDEA](https://plugins.jetbrains.com/plugin/1065-checkstyle-idea) - CodeStyle/CodeAnalysis +* Install `IntelliJ` + * Recommended setup actions + * Disable not needed plugins + * Disable [telemetry](https://www.jetbrains.com/help/idea/settings-usage-statistics.html) + * Configure the available memory * Import the project - * Ensure that everything is encoded in ``UTF-8`` + * You will get prompted to install the required plugins + * Ensure that everything is encoded in `UTF-8` * Ensure that the JDK/Java-Version is correct diff --git a/README.md b/README.md index bef64902..761ce302 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ Modules for XDEV's Testcontainer Infrastructure Framework * Data-generation template * Improved JDBC Container wait strategy * Predefined implementations for [Spring-ORM](./db-jdbc-spring-orm/), [Hibernate](./db-jdbc-spring-orm-hibernate/) and [EclipseLink](./db-jdbc-spring-orm-eclipselink/) +* [image-build](./image-build/) + * Contains some shortcuts that help with common build configuration e.g. related to caching + * Designed to work together with the [`docker-image-cache` actions](./.github/actions/docker-image-cache/) * [jacoco](./jacoco/) * Allows for recording of JaCoCo code coverage files with Java containers * [jul-to-slf4j](./jul-to-slf4j/) diff --git a/advanced-demo/README.md b/advanced-demo/README.md index b2a0d41e..170dd252 100644 --- a/advanced-demo/README.md +++ b/advanced-demo/README.md @@ -25,3 +25,4 @@ The most interesting project is probably [webapp-it](./integration-tests/webapp- * Safe starting of named containers:
Is integrated inside ``TCI``. Parts of it only kick in on unexpected container start errors. Can also be observed during tests (with e.g. ``docker stats``) as it names the containers. * Container leak detection:
Only kicks in when you forget to stop infrastructure after a test. Can be manually reproduced by commenting out the corresponding ``TCI#stop`` methods. * Tracing:
Seen at the end of each test inside the logs. +* Caching (using GitHub actions):
Cache configuration and usage can be found in [`run-integration-tests.yml`](../.github/workflows/run-integration-tests.yml). diff --git a/advanced-demo/entities-metamodel/pom.xml b/advanced-demo/entities-metamodel/pom.xml index 034a922d..d9ffd8cc 100644 --- a/advanced-demo/entities-metamodel/pom.xml +++ b/advanced-demo/entities-metamodel/pom.xml @@ -7,7 +7,7 @@ software.xdev.tci.demo advanced-demo - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT entities-metamodel diff --git a/advanced-demo/entities/pom.xml b/advanced-demo/entities/pom.xml index 13668e87..4dd287e0 100644 --- a/advanced-demo/entities/pom.xml +++ b/advanced-demo/entities/pom.xml @@ -7,7 +7,7 @@ software.xdev.tci.demo advanced-demo - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT entities diff --git a/advanced-demo/integration-tests/persistence-it-eclipselink/pom.xml b/advanced-demo/integration-tests/persistence-it-eclipselink/pom.xml index 3e4cbacb..74533ccd 100644 --- a/advanced-demo/integration-tests/persistence-it-eclipselink/pom.xml +++ b/advanced-demo/integration-tests/persistence-it-eclipselink/pom.xml @@ -7,7 +7,7 @@ software.xdev.tci.demo.it integration-tests - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT persistence-it-eclipselink diff --git a/advanced-demo/integration-tests/persistence-it-hibernate/pom.xml b/advanced-demo/integration-tests/persistence-it-hibernate/pom.xml index e4c6b8d9..cd86b3ca 100644 --- a/advanced-demo/integration-tests/persistence-it-hibernate/pom.xml +++ b/advanced-demo/integration-tests/persistence-it-hibernate/pom.xml @@ -7,7 +7,7 @@ software.xdev.tci.demo.it integration-tests - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT persistence-it-hibernate diff --git a/advanced-demo/integration-tests/persistence-it/pom.xml b/advanced-demo/integration-tests/persistence-it/pom.xml index 5e44cb8e..73fd57cd 100644 --- a/advanced-demo/integration-tests/persistence-it/pom.xml +++ b/advanced-demo/integration-tests/persistence-it/pom.xml @@ -7,7 +7,7 @@ software.xdev.tci.demo.it integration-tests - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT persistence-it diff --git a/advanced-demo/integration-tests/pom.xml b/advanced-demo/integration-tests/pom.xml index 900e70f7..f40e331b 100644 --- a/advanced-demo/integration-tests/pom.xml +++ b/advanced-demo/integration-tests/pom.xml @@ -7,12 +7,12 @@ software.xdev.tci.demo advanced-demo - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev.tci.demo.it integration-tests - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT pom @@ -30,7 +30,7 @@ software.xdev.tci.demo.it persistence-it - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT test-jar tests @@ -38,13 +38,13 @@ software.xdev.tci.demo.it tci-db - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev.tci.demo.it tci-webapp - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT @@ -67,6 +67,11 @@ db-jdbc-spring-orm-hibernate ${project.version} + + software.xdev.tci + image-build + ${project.version} + software.xdev.tci jacoco @@ -92,7 +97,7 @@ org.seleniumhq.selenium selenium-dependencies-bom - 4.45.0 + 4.46.0 pom import @@ -109,18 +114,11 @@ org.junit junit-bom - 6.1.0 + 6.1.2 pom import - - - software.xdev - testcontainers-advanced-imagebuilder - 2.5.0 - - org.javassist diff --git a/advanced-demo/integration-tests/tci-db/pom.xml b/advanced-demo/integration-tests/tci-db/pom.xml index 5cd6248c..321c5647 100644 --- a/advanced-demo/integration-tests/tci-db/pom.xml +++ b/advanced-demo/integration-tests/tci-db/pom.xml @@ -7,7 +7,7 @@ software.xdev.tci.demo.it integration-tests - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT tci-db @@ -21,6 +21,10 @@ software.xdev.tci db-jdbc-spring-orm-hibernate + + software.xdev.tci + image-build + @@ -34,10 +38,5 @@ testcontainers-jdbc compile - - - software.xdev - testcontainers-advanced-imagebuilder -
diff --git a/advanced-demo/integration-tests/tci-db/src/main/java/software/xdev/tci/demo/tci/db/containers/DBContainerBuilder.java b/advanced-demo/integration-tests/tci-db/src/main/java/software/xdev/tci/demo/tci/db/containers/DBContainerBuilder.java index 00545b95..de78f3e2 100644 --- a/advanced-demo/integration-tests/tci-db/src/main/java/software/xdev/tci/demo/tci/db/containers/DBContainerBuilder.java +++ b/advanced-demo/integration-tests/tci-db/src/main/java/software/xdev/tci/demo/tci/db/containers/DBContainerBuilder.java @@ -1,58 +1,31 @@ package software.xdev.tci.demo.tci.db.containers; import java.nio.file.Paths; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; +import java.time.Duration; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import software.xdev.tci.imagebuild.BuildImage; -import software.xdev.testcontainers.imagebuilder.AdvancedImageFromDockerFile; - -@SuppressWarnings("PMD.MoreThanOneLogger") public final class DBContainerBuilder { - private static final Logger LOG = LoggerFactory.getLogger(DBContainerBuilder.class); - private static final Logger LOG_CONTAINER_BUILD = - LoggerFactory.getLogger("container.build.db"); - - private static String builtImageName; - private DBContainerBuilder() { } - public static synchronized String getBuiltImageName() + public static String getImageName() { - if(builtImageName != null) - { - return builtImageName; - } - - LOG.info("Building Webapp-db-DockerImage..."); - - final AdvancedImageFromDockerFile builder = - new AdvancedImageFromDockerFile("webapp-db", false) - .withLoggerForBuild(LOG_CONTAINER_BUILD) - .withPostGitIgnoreLines( - // Ignore everything - "**") + return BuildImage.nativeImage( + "tci-demo-db", + Duration.ofMinutes(5), + builder -> builder .withDockerFilePath(Paths.get("../tci-db/Dockerfile")) .withBaseDir(Paths.get("../")) - .withBaseDirRelativeIgnoreFile(null); - - try - { - builtImageName = builder.get(5, TimeUnit.MINUTES); - } - catch(final TimeoutException tex) - { - throw new IllegalStateException("Timed out", tex); - } - - LOG.info("Built Image; Name ='{}'", builtImageName); - - return builtImageName; + .configureFilesToTransferHandler(h -> h + .withPostGitIgnoreLines( + // Ignore everything + "**") + .withBaseDirRelativeIgnoreFile(null) + ) + ); } } diff --git a/advanced-demo/integration-tests/tci-db/src/main/java/software/xdev/tci/demo/tci/db/factory/DBTCIFactory.java b/advanced-demo/integration-tests/tci-db/src/main/java/software/xdev/tci/demo/tci/db/factory/DBTCIFactory.java index c4a0ca85..77bd06bd 100644 --- a/advanced-demo/integration-tests/tci-db/src/main/java/software/xdev/tci/demo/tci/db/factory/DBTCIFactory.java +++ b/advanced-demo/integration-tests/tci-db/src/main/java/software/xdev/tci/demo/tci/db/factory/DBTCIFactory.java @@ -1,5 +1,10 @@ package software.xdev.tci.demo.tci.db.factory; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +import software.xdev.tci.concurrent.Suppliers; +import software.xdev.tci.concurrent.TCIExecutorServiceHolder; import software.xdev.tci.db.factory.BaseDBTCIFactory; import software.xdev.tci.demo.tci.db.DBTCI; import software.xdev.tci.demo.tci.db.containers.DBContainer; @@ -10,6 +15,8 @@ public class DBTCIFactory extends BaseDBTCIFactory { + protected static final Supplier IMAGE_NAME_SUPPLIER = Suppliers.memoize(DBContainerBuilder::getImageName); + public DBTCIFactory() { this(true); @@ -20,7 +27,7 @@ public DBTCIFactory(final boolean migrateAndInitializeEMC) { super( (c, n) -> new DBTCI(c, n, migrateAndInitializeEMC), - () -> new DBContainer(DBContainerBuilder.getBuiltImageName()) + () -> new DBContainer(IMAGE_NAME_SUPPLIER.get()) .withDatabaseName(DBTCI.DB_DATABASE) .withCreateContainerCmdModifier(cmd -> cmd.getHostConfig().withMemory(ContainerMemory.M512M))); this.withSnapshotManager(new CommitedImageSnapshotManager("/var/lib/mysql")); @@ -29,7 +36,7 @@ public DBTCIFactory(final boolean migrateAndInitializeEMC) @Override protected void warmUpInternal() { - DBContainerBuilder.getBuiltImageName(); + CompletableFuture.runAsync(IMAGE_NAME_SUPPLIER::get, TCIExecutorServiceHolder.instance()); super.warmUpInternal(); } } diff --git a/advanced-demo/integration-tests/tci-webapp/Dockerfile b/advanced-demo/integration-tests/tci-webapp/Dockerfile index f48cab54..66125eee 100644 --- a/advanced-demo/integration-tests/tci-webapp/Dockerfile +++ b/advanced-demo/integration-tests/tci-webapp/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1-labs # Note: This Dockerfile is used by the integration tests for compiling the app when there was no Image was supplied -ARG JAVA_VERSION=21 +ARG JAVA_VERSION=25 FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS jre-base @@ -74,8 +74,10 @@ RUN mv webapp/target/webapp.jar app.jar \ FROM jre-minimized +ARG ENABLE_AOT + ARG JACOCO_AGENT_ENABLED -ARG JACOCO_AGENT_VERSION="0.8.14" +ARG JACOCO_AGENT_VERSION="0.8.15" ARG user=app ARG group=app @@ -110,9 +112,48 @@ COPY --from=builder --chown=${user}:${group} /builder/extracted/snapshot-depende RUN true COPY --from=builder --chown=${user}:${group} /builder/extracted/application/ ./ +# RUN AOT and boot with dummy configuration +RUN if [[ ! -z "$ENABLE_AOT" ]]; then \ + java \ + -XX:+UseG1GC \ + -XX:+UseCompactObjectHeaders \ + -XX:+UseStringDeduplication \ + -XX:AOTCacheOutput=app.aot \ + -Djava.awt.headless=true \ + -Dspring.context.exit=onRefresh \ + # Disable Flyway + -Dspring.flyway.enabled=false \ + # Disabled DB actuator endpoint + -Dmanagement.health.db.enabled=false \ + # Disbale DB instant connection + -Dspring.jpa.properties.jakarta.persistence.database-product-name=MariaDB \ + -Dspring.jpa.properties.jakarta.persistence.database-major-version=11 \ + -Dspring.jpa.properties.jakarta.persistence.database-minor-version=8 \ + -Dspring.jpa.properties.hibernate.boot.allow.jdbc.metadata.access=false \ + # Fake DB + -Dspring.datasource.url=jdbc:mariadb://localhost:3306/dummy \ + -Dspring.datasource.username=dummy \ + -Dspring.datasource.password=dummy \ + # Fake Auth + -Dspring.security.oauth2.client.registration.local.authorization-grant-type=authorization_code \ + -Dspring.security.oauth2.client.registration.local.redirect-uri="{baseUrl}/{action}/oauth2/code/{registrationId}" \ + -Dspring.security.oauth2.client.provider.local.authorization-uri=http://localhost/connect/authorize \ + -Dspring.security.oauth2.client.provider.local.token-uri=http://localhost/connect/token \ + -Dspring.security.oauth2.client.provider.local.jwk-set-uri=http://localhost/.well-known/openid-configuration/jwks \ + -Dspring.security.oauth2.client.provider.local.user-info-uri=http://localhost/connect/userinfo \ + -Dspring.security.oauth2.client.provider.local.user-info-authentication-method=header \ + -Dspring.security.oauth2.client.provider.local.user-name-attribute=sub \ + -Dspring.security.oauth2.client.registration.local.client-name=Local \ + -Dspring.security.oauth2.client.registration.local.client-id=dummy \ + -Dspring.security.oauth2.client.registration.local.client-secret=dummy \ + -Dspring.security.oauth2.client.registration.local.scope="openid,profile,email,offline_access" \ + -jar app.jar; \ + fi + ENV JAVA_JACOCO_OPTS=${JACOCO_AGENT_ENABLED:+"-javaagent:/jacoco/agent.jar=destfile=/jacoco/report/jacoco.exec"} -ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:MinHeapFreeRatio=20 -XX:MaxHeapFreeRatio=30 -Djava.awt.headless=true" +ENV JAVA_OPTS="-XX:+UseG1GC -XX:MaxRAMPercentage=75 -XX:MinHeapFreeRatio=20 -XX:MaxHeapFreeRatio=30 -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -Djava.awt.headless=true" +ENV JAVA_AOT_OPTS=${ENABLE_AOT:+"-XX:AOTCache=app.aot"} EXPOSE 8080 -CMD [ "/bin/sh", "-c", "java $JAVA_OPTS $JAVA_JACOCO_OPTS -jar /opt/app/app.jar" ] +CMD [ "/bin/sh", "-c", "java $JAVA_OPTS $JAVA_JACOCO_OPTS $JAVA_AOT_OPTS -jar /opt/app/app.jar" ] diff --git a/advanced-demo/integration-tests/tci-webapp/pom.xml b/advanced-demo/integration-tests/tci-webapp/pom.xml index affcb9cd..2ddfffcd 100644 --- a/advanced-demo/integration-tests/tci-webapp/pom.xml +++ b/advanced-demo/integration-tests/tci-webapp/pom.xml @@ -7,7 +7,7 @@ software.xdev.tci.demo.it integration-tests - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT tci-webapp @@ -18,11 +18,11 @@ software.xdev.tci - jacoco + image-build - software.xdev - testcontainers-advanced-imagebuilder + software.xdev.tci + jacoco diff --git a/advanced-demo/integration-tests/tci-webapp/src/main/java/software/xdev/tci/demo/tci/webapp/containers/WebAppContainer.java b/advanced-demo/integration-tests/tci-webapp/src/main/java/software/xdev/tci/demo/tci/webapp/containers/WebAppContainer.java index 56a6e63c..4b7bf5d0 100644 --- a/advanced-demo/integration-tests/tci-webapp/src/main/java/software/xdev/tci/demo/tci/webapp/containers/WebAppContainer.java +++ b/advanced-demo/integration-tests/tci-webapp/src/main/java/software/xdev/tci/demo/tci/webapp/containers/WebAppContainer.java @@ -3,12 +3,15 @@ import java.time.Duration; import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; -import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import com.github.dockerjava.api.command.InspectContainerResponse; import com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.HttpStatus; import software.xdev.tci.jacoco.containers.JaCoCoAwareContainer; +import software.xdev.tci.startup.error.java.fatal.HsErrPidStartUpCrashReporter; +import software.xdev.tci.startup.wait.FastAbortOnContainerDeathWaitStrategy; +import software.xdev.tci.startup.wait.strategy.HostPortWaitAbortableStrategy; +import software.xdev.tci.startup.wait.strategy.HttpWaitAbortableStrategy; @SuppressWarnings("java:S2160") @@ -17,6 +20,7 @@ public class WebAppContainer extends GenericContainer implement public static final int DEFAULT_HTTP_PORT = 8080; protected final boolean connectionlessStart; + protected final HsErrPidStartUpCrashReporter hsErrPidStartUpCrashReporter; public WebAppContainer(final String dockerImageName, final boolean connectionlessStart) { @@ -27,6 +31,7 @@ public WebAppContainer(final String dockerImageName, final boolean connectionles this.withConnectionlessStart(); } this.addExposedPort(DEFAULT_HTTP_PORT); + this.hsErrPidStartUpCrashReporter = new HsErrPidStartUpCrashReporter(this); } public WebAppContainer withDB(final String jdbcUrl, final String username, final String password) @@ -88,7 +93,7 @@ public WebAppContainer withConnectionlessStart() .withEnv("MANAGEMENT_HEALTH_DB_ENABLED", false) .withEnv(springJpa + "PROPERTIES_JAKARTA_PERSISTENCE_DATABASE-PRODUCT-NAME", "MariaDB") .withEnv(springJpa + "PROPERTIES_JAKARTA_PERSISTENCE_DATABASE-MAJOR-VERSION", "11") - .withEnv(springJpa + "PROPERTIES_JAKARTA_PERSISTENCE_DATABASE-MINOR-VERSION", "4") + .withEnv(springJpa + "PROPERTIES_JAKARTA_PERSISTENCE_DATABASE-MINOR-VERSION", "8") .withEnv(springJpa + "PROPERTIES_HIBERNATE_BOOT_ALLOW_JDBC_METADATA_ACCESS", false); } @@ -107,23 +112,36 @@ public WebAppContainer withDefaultWaitStrategy( final String actuatorUsername, final String actuatorPassword) { - return this.waitingFor(new WaitAllStrategy() + return this.waitingFor(FastAbortOnContainerDeathWaitStrategy.waitAll(s -> s .withStartupTimeout(startUpTimeout) - .withStrategy( - new HttpWaitStrategy() + .withStrategy(new HostPortWaitAbortableStrategy()) + .withStrategy(new HttpWaitAbortableStrategy() .forPort(WebAppContainer.DEFAULT_HTTP_PORT) .forPath("/robots.txt") .forStatusCode(HttpStatus.SC_OK) - .withReadTimeout(Duration.ofSeconds(10)) - ) - .withStrategy( - new HttpWaitStrategy() + .withReadTimeout(Duration.ofSeconds(10))) + .withStrategy(new HttpWaitAbortableStrategy() .forPort(WebAppContainer.DEFAULT_HTTP_PORT) .forPath("/actuator/health") .withBasicCredentials(actuatorUsername, actuatorPassword) .forStatusCode(HttpStatus.SC_OK) .withReadTimeout(Duration.ofSeconds(10)) - )); + )) + ); + } + + @Override + protected void containerIsStarted(final InspectContainerResponse containerInfo, final boolean reused) + { + this.hsErrPidStartUpCrashReporter.containerIsStarted(); + super.containerIsStarted(containerInfo, reused); + } + + @Override + protected void containerIsStopping(final InspectContainerResponse containerInfo) + { + this.hsErrPidStartUpCrashReporter.containerIsStopping(this.logger()); + super.containerIsStopping(containerInfo); } @SuppressWarnings("unused") diff --git a/advanced-demo/integration-tests/tci-webapp/src/main/java/software/xdev/tci/demo/tci/webapp/containers/WebAppContainerBuilder.java b/advanced-demo/integration-tests/tci-webapp/src/main/java/software/xdev/tci/demo/tci/webapp/containers/WebAppContainerBuilder.java index e6bffafb..02f6c01f 100644 --- a/advanced-demo/integration-tests/tci-webapp/src/main/java/software/xdev/tci/demo/tci/webapp/containers/WebAppContainerBuilder.java +++ b/advanced-demo/integration-tests/tci-webapp/src/main/java/software/xdev/tci/demo/tci/webapp/containers/WebAppContainerBuilder.java @@ -1,122 +1,99 @@ package software.xdev.tci.demo.tci.webapp.containers; -import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Duration; import java.util.List; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import org.apache.commons.compress.archivers.tar.TarArchiveEntry; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import software.xdev.tci.imagebuild.BuildImage; import software.xdev.tci.jacoco.testbase.config.JaCoCoConfig; -import software.xdev.testcontainers.imagebuilder.AdvancedImageFromDockerFile; import software.xdev.testcontainers.imagebuilder.compat.DockerfileCOPYParentsEmulator; import software.xdev.testcontainers.imagebuilder.transfer.fcm.FileLinesContentModifier; -@SuppressWarnings("PMD.MoreThanOneLogger") public final class WebAppContainerBuilder { - private static final Logger LOG = LoggerFactory.getLogger(WebAppContainerBuilder.class); - private static final Logger LOG_CONTAINER_BUILD = - LoggerFactory.getLogger("container.build.webapp"); - - private static String builtImageName; - private WebAppContainerBuilder() { } - public static synchronized String getBuiltImageName() + public static String getImageName() { - if(builtImageName != null) - { - return builtImageName; - } - - LOG.info("Building WebApp-DockerImage..."); - - final AdvancedImageFromDockerFile builder = - new AdvancedImageFromDockerFile("webapp-it-local", false) - .withLoggerForBuild(LOG_CONTAINER_BUILD) - .withPostGitIgnoreLines( - // Ignore git-folder, as it will be provided in the Dockerfile - ".git/**", - // Ignore other unused folders and extensions - "*.iml", - "*.cmd", - "*.md", - "_dev_infra/**", - "_resource_metrics/**", - // Ignore other Dockerfiles (our required file will always be transferred) - "Dockerfile", - // Ignore not required test-modules that may have changed - // sources only - otherwise the parent pom doesn't find the resources - "integration-tests/**", - "**/src/test/**", - // Ignore resources that are just used for development - "webapp/src/main/resources-dev/**", - // Most files from these folders need to be ignored -> Down there for highest prio - "node_modules", - "target") - .withDockerFilePath(Paths.get("../../integration-tests/tci-webapp/Dockerfile")) - .withBaseDir(Paths.get("../../")) - // File is in root directory - we can't access it - .withBaseDirRelativeIgnoreFile(null) - .withDockerFileLinesModifier(new DockerfileCOPYParentsEmulator()) - .withTransferArchiveTARCompressorCustomizer(c -> c - // Rewrite parent pom to exclude integration tests - // This way changes in test pom's cause no redownload of dependencies - .withContentModifier(new FileLinesContentModifier() - { - @Override - public boolean shouldApply( - final Path sourcePath, - final String targetPath, - final TarArchiveEntry tarArchiveEntry) - { - return "pom.xml".equals(targetPath); - } - - @Override - public List modify( - final List lines, - final Path sourcePath, - final String targetPath, - final TarArchiveEntry tarArchiveEntry) throws IOException - { - return lines.stream() - // Remove integration tests module - .filter(s -> !s.contains("integration-tests")) - .toList(); - } - - @Override - public boolean isIdentical(final List original, final List created) - { - return original.size() == created.size(); - } - })); - - if(JaCoCoConfig.instance().enabled()) - { - builder.withBuildArg("JACOCO_AGENT_ENABLED", "1"); - } - - try - { - builtImageName = builder.get(5, TimeUnit.MINUTES); - } - catch(final TimeoutException tex) - { - throw new IllegalStateException("Timed out", tex); - } - - LOG.info("Built Image; Name ='{}'", builtImageName); - - return builtImageName; + return BuildImage.nativeImage( + "tci-demo-webapp", + Duration.ofMinutes(5), + builder -> { + builder + // NOTE: AOT can't be used properly when JaCoCo is active + .withBuildArg("ENABLE_AOT", "1") + .withDockerFilePath(Paths.get("../../integration-tests/tci-webapp/Dockerfile")) + .withBaseDir(Paths.get("../../")) + .configureFilesToTransferHandler(h -> h + .withPostGitIgnoreLines( + // Ignore git-folder, as it will be provided in the Dockerfile + ".git/**", + // Ignore other unused folders and extensions + "*.iml", + "*.cmd", + "*.md", + "_dev_infra/**", + "_resource_metrics/**", + // Ignore other Dockerfiles (our required file will always be transferred) + "Dockerfile", + // Ignore not required test-modules that may have changed + // sources only - otherwise the parent pom doesn't find the resources + "integration-tests/**", + "**/src/test/**", + // Ignore resources that are just used for development + "webapp/src/main/resources-dev/**", + // Most files from these folders need to be ignored -> Down there for highest prio + "node_modules", + "target") + // File is in root directory - we can't access it + .withBaseDirRelativeIgnoreFile(null) + .withDockerFileLinesModifier(new DockerfileCOPYParentsEmulator()) + .withTransferArchiveTARCompressorCustomizer(c -> c + // Rewrite parent pom to exclude integration tests + // This way changes in test pom's cause no redownload of dependencies + .withContentModifier(new FileLinesContentModifier() + { + @Override + public boolean shouldApply( + final Path sourcePath, + final String targetPath, + final TarArchiveEntry tarArchiveEntry) + { + return "pom.xml".equals(targetPath); + } + + @Override + public List modify( + final List lines, + final Path sourcePath, + final String targetPath, + final TarArchiveEntry tarArchiveEntry) + { + return lines.stream() + // Remove integration tests module + .filter(s -> !s.contains("integration-tests")) + .toList(); + } + + @Override + public boolean isIdentical(final List original, final List created) + { + return original.size() == created.size(); + } + }))); + + if(JaCoCoConfig.instance().enabled()) + { + builder.withBuildArg("JACOCO_AGENT_ENABLED", "1"); + } + + return builder; + }); } } diff --git a/advanced-demo/integration-tests/tci-webapp/src/main/java/software/xdev/tci/demo/tci/webapp/factory/WebAppTCIFactory.java b/advanced-demo/integration-tests/tci-webapp/src/main/java/software/xdev/tci/demo/tci/webapp/factory/WebAppTCIFactory.java index 05498a62..848b0b7b 100644 --- a/advanced-demo/integration-tests/tci-webapp/src/main/java/software/xdev/tci/demo/tci/webapp/factory/WebAppTCIFactory.java +++ b/advanced-demo/integration-tests/tci-webapp/src/main/java/software/xdev/tci/demo/tci/webapp/factory/WebAppTCIFactory.java @@ -1,8 +1,12 @@ package software.xdev.tci.demo.tci.webapp.factory; import java.time.Duration; +import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; +import java.util.function.Supplier; +import software.xdev.tci.concurrent.Suppliers; +import software.xdev.tci.concurrent.TCIExecutorServiceHolder; import software.xdev.tci.demo.tci.webapp.WebAppTCI; import software.xdev.tci.demo.tci.webapp.containers.WebAppContainer; import software.xdev.tci.demo.tci.webapp.containers.WebAppContainerBuilder; @@ -13,9 +17,8 @@ public class WebAppTCIFactory extends PreStartableTCIFactory { - public static final String PROPERTY_APP_DOCKERIMAGE = "appDockerImage"; - - protected static String appImageName; + protected static final Supplier IMAGE_NAME_SUPPLIER = + Suppliers.memoize(WebAppContainerBuilder::getImageName); @SuppressWarnings("checkstyle:MagicNumber") public WebAppTCIFactory(final Consumer additionalContainerBuilder) @@ -23,7 +26,7 @@ public WebAppTCIFactory(final Consumer additionalContainerBuild super( WebAppTCI::new, () -> { - final WebAppContainer container = new WebAppContainer(getAppImageName(), true) + final WebAppContainer container = new WebAppContainer(IMAGE_NAME_SUPPLIER.get(), true) .withDefaultWaitStrategy( Duration.ofSeconds(40L + 20L * EnvironmentPerformance.cpuSlownessFactor()), WebAppTCI.ACTUATOR_USERNAME, @@ -47,23 +50,7 @@ public WebAppTCIFactory(final Consumer additionalContainerBuild @Override protected void warmUpInternal() { - getAppImageName(); + CompletableFuture.runAsync(IMAGE_NAME_SUPPLIER::get, TCIExecutorServiceHolder.instance()); super.warmUpInternal(); } - - protected static synchronized String getAppImageName() - { - if(appImageName != null) - { - return appImageName; - } - - appImageName = System.getProperty(PROPERTY_APP_DOCKERIMAGE); - if(appImageName == null) - { - appImageName = WebAppContainerBuilder.getBuiltImageName(); - } - - return appImageName; - } } diff --git a/advanced-demo/integration-tests/webapp-it/pom.xml b/advanced-demo/integration-tests/webapp-it/pom.xml index e2c0a2a1..1e713731 100644 --- a/advanced-demo/integration-tests/webapp-it/pom.xml +++ b/advanced-demo/integration-tests/webapp-it/pom.xml @@ -7,7 +7,7 @@ software.xdev.tci.demo.it integration-tests - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT webapp-it diff --git a/advanced-demo/integration-tests/webapp-it/src/test/java/software/xdev/tci/demo/webapp/cases/LongButLittleResourceUsageTest.java b/advanced-demo/integration-tests/webapp-it/src/test/java/software/xdev/tci/demo/webapp/cases/LongButLittleResourceUsageTest.java index 29614c07..e5959b8f 100644 --- a/advanced-demo/integration-tests/webapp-it/src/test/java/software/xdev/tci/demo/webapp/cases/LongButLittleResourceUsageTest.java +++ b/advanced-demo/integration-tests/webapp-it/src/test/java/software/xdev/tci/demo/webapp/cases/LongButLittleResourceUsageTest.java @@ -33,7 +33,7 @@ void simulatedLongTest(final TestBrowser browser) try { - Thread.sleep(20_000); + Thread.sleep(15_000); } catch(final InterruptedException iex) { diff --git a/advanced-demo/persistence/pom.xml b/advanced-demo/persistence/pom.xml index 27577c3b..363a9c36 100644 --- a/advanced-demo/persistence/pom.xml +++ b/advanced-demo/persistence/pom.xml @@ -7,7 +7,7 @@ software.xdev.tci.demo advanced-demo - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT persistence diff --git a/advanced-demo/pom.xml b/advanced-demo/pom.xml index 4425da10..5b25f2c3 100644 --- a/advanced-demo/pom.xml +++ b/advanced-demo/pom.xml @@ -6,7 +6,7 @@ software.xdev.tci.demo advanced-demo - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT pom @@ -38,19 +38,19 @@ software.xdev.tci.demo entities - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev.tci.demo entities-metamodel - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev.tci.demo persistence - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT @@ -70,7 +70,7 @@ org.junit junit-bom - 6.1.0 + 6.1.2 pom import @@ -109,7 +109,7 @@ software.xdev.sse bom - 2.5.0 + 2.5.1 pom import @@ -195,7 +195,7 @@ com.puppycrawl.tools checkstyle - 13.6.0 + 13.8.0 @@ -236,12 +236,12 @@ net.sourceforge.pmd pmd-core - 7.25.0 + 7.26.0 net.sourceforge.pmd pmd-java - 7.25.0 + 7.26.0 diff --git a/advanced-demo/webapp/Dockerfile b/advanced-demo/webapp/Dockerfile deleted file mode 100644 index b20b1c25..00000000 --- a/advanced-demo/webapp/Dockerfile +++ /dev/null @@ -1,76 +0,0 @@ -FROM eclipse-temurin:21-jre-alpine AS jre-base - - -# Build the JRE ourself and exclude stuff from Eclipse-Temurin that we don't need -# -# Derived from https://github.com/adoptium/containers/blob/91ea190c462741d2c64ed2f8f0a0efdb3e77c49d/21/jre/alpine/3.21/Dockerfile -FROM alpine:3 AS jre-minimized - -ENV JAVA_HOME=/opt/java/openjdk -ENV PATH=$JAVA_HOME/bin:$PATH - -ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' LC_ALL='en_US.UTF-8' - -RUN set -eux; \ - # DO NOT INSTALL: - # gnupg - only required to verify download of jre from eclipse-temurin - # fontconfig ttf-dejavu - No fonts are needed (we also don't use AWT) - # everything that works with certificates (ca-certificates p11-kit-trust coreutils openssl) - as we don't do stuff with certificates - # tzdata - We are using UTC (and everyone should do that) - apk add --no-cache \ - musl-locales musl-locales-lang - -COPY --from=jre-base /opt/java/openjdk /opt/java/openjdk - -RUN set -eux; \ - echo "Verifying install ..."; \ - echo "java --version"; java --version; \ - echo "Complete." - -# Renamed as cacerts functionality is disabled -COPY --from=jre-base /__cacert_entrypoint.sh /entrypoint.sh -RUN chmod 775 /entrypoint.sh -ENTRYPOINT ["/entrypoint.sh"] - - -# See also https://docs.spring.io/spring-boot/reference/packaging/container-images/dockerfiles.html for further information -FROM jre-minimized AS builder - -WORKDIR /builder - -COPY target/webapp.jar app.jar - -RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted - - -FROM jre-minimized - -ARG user=app -ARG group=app -ARG uid=1000 -ARG gid=1000 -ARG APP_DIR=/opt/app - -# Create user + group + home -RUN mkdir -p ${APP_DIR} \ - && chown ${uid}:${gid} ${APP_DIR} \ - && addgroup -g ${gid} ${group} \ - && adduser -h "$APP_DIR" -u ${uid} -G ${group} -s /bin/bash -D ${user} - -WORKDIR ${APP_DIR} - -USER ${user} - -COPY --from=builder --chown=${user}:${group} /builder/extracted/dependencies/ ./ -COPY --from=builder --chown=${user}:${group} /builder/extracted/spring-boot-loader/ ./ -COPY --from=builder --chown=${user}:${group} /builder/extracted/snapshot-dependencies/ ./ -COPY --from=builder --chown=${user}:${group} /builder/extracted/application/ ./ - -# MaxRAMPercentage: Default value is 25% -> we want to use available memory optimal -> increased, but enough is left for other RAM usages like e.g. Metaspace -# Min/MaxHeapFreeRatio: Default values cause container reserved memory not to shrink properly/waste memory -> decreased -# https://stackoverflow.com/questions/16058250/what-is-the-purpose-of-xxminheapfreeratio-and-xxmaxheapfreeratio -ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:MinHeapFreeRatio=20 -XX:MaxHeapFreeRatio=30 -Djava.awt.headless=true" - -EXPOSE 8080 - -CMD [ "/bin/sh", "-c", "java $JAVA_OPTS -jar app.jar" ] diff --git a/advanced-demo/webapp/pom.xml b/advanced-demo/webapp/pom.xml index 0a831534..8c853a8c 100644 --- a/advanced-demo/webapp/pom.xml +++ b/advanced-demo/webapp/pom.xml @@ -7,7 +7,7 @@ software.xdev.tci.demo advanced-demo - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT webapp diff --git a/advanced-demo/webapp/src/main/resources/application.yml b/advanced-demo/webapp/src/main/resources/application.yml index ada5d52a..e6a4def9 100644 --- a/advanced-demo/webapp/src/main/resources/application.yml +++ b/advanced-demo/webapp/src/main/resources/application.yml @@ -1,9 +1,14 @@ spring: jpa: + bootstrap: async open-in-view: false hibernate: ddl-auto: none properties: + jakarta: + persistence: + validation: + mode: none hibernate: hbm2ddl: auto: none diff --git a/base-demo/pom.xml b/base-demo/pom.xml index d10c5824..ec51c51e 100644 --- a/base-demo/pom.xml +++ b/base-demo/pom.xml @@ -7,11 +7,11 @@ software.xdev.tci root - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT base-demo - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT jar @@ -38,14 +38,14 @@ org.junit.jupiter junit-jupiter - 6.1.0 + 6.1.2 test ch.qos.logback logback-classic - 1.5.36 + 1.5.38 test diff --git a/base/README.md b/base/README.md index 866eeab8..a8222fb2 100644 --- a/base/README.md +++ b/base/README.md @@ -6,15 +6,73 @@ Base for other modules and core components. | Feature | Why? | Demo | | --- | --- | --- | -| Easily create infrastructure using TCI[JD](https://javadoc.io/doc/software.xdev/base/latest/software/xdev/tci/TCI.html) (TestContainer Infrastructure) templating + Factories for that | Makes writing and designing tests easier | [here](../base-demo/src/test/java/software/xdev/tci/dummyinfra/) | -| [PreStarting mechanism](./src/main/java/software/xdev/tci/factory/prestart/)[JD](https://javadoc.io/doc/software.xdev/base/latest/software/xdev/tci/factory/prestart/PreStartableTCIFactory.html) for [additional performance](../PERFORMANCE.md) | Tries to run tests as fast as possible - with a few trade-offs | [here](../base-demo/src/test/java/software/xdev/tci/factory/prestart/) | +| Easily create infrastructure using TCI[JD](https://javadoc.io/doc/software.xdev.tci/base/latest/software/xdev/tci/TCI.html) (TestContainer Infrastructure) templating + Factories for that | Makes writing and designing tests easier | [here](../base-demo/src/test/java/software/xdev/tci/dummyinfra/) | +| [PreStarting mechanism](./src/main/java/software/xdev/tci/factory/prestart/)[JD](https://javadoc.io/doc/software.xdev.tci/base/latest/software/xdev/tci/factory/prestart/PreStartableTCIFactory.html) for [additional performance](../PERFORMANCE.md) | Tries to run tests as fast as possible - with a few trade-offs | [here](../base-demo/src/test/java/software/xdev/tci/factory/prestart/) | | All started containers have a unique human-readable name | Easier identification when tracing or debugging | [here](../base-demo/src/test/java/software/xdev/tci/safestart/) | -| An optimized [implementation of Network](./src/main/java/software/xdev/tci/network/)[JD](https://javadoc.io/doc/software.xdev/base/latest/software/xdev/tci/network/LazyNetwork.html) | Addresses various problems of the original implementation to speed up tests | [here](../base-demo/src/test/java/software/xdev/tci/network/) | -| [Safe starting of named containers](./src/main/java/software/xdev/tci/safestart/)[JD](https://javadoc.io/doc/software.xdev/base/latest/software/xdev/tci/safestart/SafeNamedContainerStarter.html) | Ensures that a container doesn't enter a crash loop during retried startups | [here](../base-demo/src/test/java/software/xdev/tci/safestart/) | +| An optimized [implementation of Network](./src/main/java/software/xdev/tci/network/)[JD](https://javadoc.io/doc/software.xdev.tci/base/latest/software/xdev/tci/network/LazyNetwork.html) | Addresses various problems of the original implementation to speed up tests | [here](../base-demo/src/test/java/software/xdev/tci/network/) | +| [Safe starting of named containers](./src/main/java/software/xdev/tci/safestart/)[JD](https://javadoc.io/doc/software.xdev.tci/base/latest/software/xdev/tci/safestart/SafeNamedContainerStarter.html) | Ensures that a container doesn't enter a crash loop during retried startups | [here](../base-demo/src/test/java/software/xdev/tci/safestart/) | | [Container Leak detection](./src/main/java/software/xdev/tci/leakdetection/)¹ | Prevents you from running out of resources | [here](../base-demo/src/test/java/software/xdev/tci/leak/) | | [Tracing](./src/main/java/software/xdev/tci/tracing/)¹ | Makes finding bottlenecks and similar problems easier | | +| [Reporting of fatal (Java) crashes during container startup](./src/main/java/software/xdev/tci/startup/error/java/fatal/) | Easier error diagnosis | [here](../advanced-demo/integration-tests/tci-webapp/src/main/java/software/xdev/tci/demo/tci/webapp/containers/WebAppContainer.java) | +| [Fail-fast/Abortable wait strategies](./src/main/java/software/xdev/tci/startup/wait/) | Fail fast when a container crashes - don't wait until the timeout | [here](../advanced-demo/integration-tests/tci-webapp/src/main/java/software/xdev/tci/demo/tci/webapp/containers/WebAppContainer.java) | ¹ = Active by default due to service loading ## Usage Take a look at the [minimalistic demo](../base-demo/) that showcases the components individually. + +## Config + +### PreStart + +
The configuration is dynamically loaded from (sorted by highest priority) + +* Environment variables + * prefixed with `TCI_INFRA-PRE-START__`* + * prefixed with `TCI_INFRA-PRE-START_` + * all properties are in UPPERCASE and use `_` instead of `.` or `-` +* System properties + * prefixed with `tci.infra-pre-start..`* + * prefixed with `tci.infra-pre-start.` + +_NOTE: `*` indicates that only some properties support this_ + +
+ +
Full list of configuration options + +| Property | Type | Default | Notes | +| --- | --- | --- | --- | +| `enabled` | `bool` | `false` | Should PreStarting be enabled? | +| `keep-ready`* | `int` | [`junit.jupiter.execution.parallel.`
`config.fixed.max-pool-size`](https://docs.junit.org/6.1.2/writing-tests/parallel-execution.html) or `1` | How many container should be kept ready for use in the background?
Setting this to a value `< 0` will effectively disable PreStarting | +| `max-start-simultan`* | `int` | [`junit.jupiter.execution.parallel.`
`config.fixed.max-pool-size`](https://docs.junit.org/6.1.2/writing-tests/parallel-execution.html) or `1` | Maximum amount of containers that should be started simultaneously
Setting a negative value will remove this limitation | +| `direct-network-attach-if-possible`* | `bool` | `true` |
  • true - Directly attaches the container to the network during startup if possible
  • false - Always performs a network-connect as if PreStarting is active. This is slower, however it emulates PreStarting better and may help with finding bugs.
| +| `fixate-exposed-ports-if-required`* | `bool` | `true` | Fixates exposed ports when no direct network attach is possible. This is a workaround for moby/moby#44137. | +| `coordinator.idle-cpu-percent` | `int` | `40`% | Amount of CPU that needs to be idle to allow PreStarting of containers | +| `coordinator.schedule-period-ms` | `int` | `1000` (1s) | How often PreStarting (one factory) should be tried | +| `detect-ending-tests` | `bool` | `true` | Should PreStarting be stopped when tests are ending? | + +_NOTE: Properties marked with `*` can additionally can use the `preStartName` for configuration. Example: `tci.infra-pre-start.my-webapp.`_ + +
+ +### Leak Detection + +
The configuration is dynamically loaded from (sorted by highest priority) + +* Environment variables + * prefixed with `TCI_LEAK-DETECTION_` + * all properties are in UPPERCASE and use `_` instead of `.` or `-` +* System properties + * prefixed with `tci.leak-detection.` + +
+ +
Full list of configuration options + +| Property | Type | Default | Notes | +| --- | --- | --- | --- | +| `enabled` | `bool` | `false` | Should Leak-Detection be enabled? | +| `stop-timeout-ms` | `int` | ~`20000` (~20s) actual value depends on cpuSlownessFactor | How long to wait until all infrastructure is stopped after tests have ended | + +
diff --git a/base/pom.xml b/base/pom.xml index 853c63e2..09c85580 100644 --- a/base/pom.xml +++ b/base/pom.xml @@ -6,7 +6,7 @@ software.xdev.tci base - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT jar base @@ -69,14 +69,14 @@ org.junit.platform junit-platform-launcher compile - 6.1.0 + 6.1.2
org.junit.jupiter junit-jupiter - 6.1.0 + 6.1.2 test @@ -136,6 +136,28 @@ org.apache.maven.plugins maven-compiler-plugin 3.15.0 + + + compile-java-21 + + compile + + + + compile-java-25 + compile + + compile + + + 25 + + ${project.basedir}/src/main/java25 + + true + + + ${maven.compiler.release} @@ -284,7 +306,7 @@ com.puppycrawl.tools checkstyle - 13.6.0 + 13.8.0 @@ -322,12 +344,12 @@ net.sourceforge.pmd pmd-core - 7.25.0 + 7.26.0 net.sourceforge.pmd pmd-java - 7.25.0 + 7.26.0 diff --git a/base/src/main/java/software/xdev/tci/concurrent/Suppliers.java b/base/src/main/java/software/xdev/tci/concurrent/Suppliers.java new file mode 100644 index 00000000..c1780e63 --- /dev/null +++ b/base/src/main/java/software/xdev/tci/concurrent/Suppliers.java @@ -0,0 +1,119 @@ +/* + * Copyright © 2024 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.concurrent; + +import java.util.Objects; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; + + +/** + * Based on + * + * Guava Supplier + * + * and minimized. + */ +public final class Suppliers +{ + private Suppliers() + { + } + + /** + * Returns a supplier which caches the instance retrieved during the first call to {@code get()} and returns that + * value on subsequent calls to {@code get()}. See: memoization + * + *

The returned supplier is thread-safe. The delegate's {@code get()} method will be invoked at + * most once unless the underlying {@code get()} throws an exception. The supplier's serialized form does not + * contain the cached value, which will be recalculated when {@code get()} is called on the deserialized instance. + * + *

When the underlying delegate throws an exception then this memoizing supplier will keep + * delegating calls until it returns valid data. + * + *

If {@code delegate} is an instance created by an earlier call to {@code memoize}, it is + * returned directly. + */ + public static Supplier memoize(final Supplier delegate) + { + if(delegate instanceof NonSerializableMemoizingSupplier) + { + return delegate; + } + return new NonSerializableMemoizingSupplier<>(delegate); + } + + @SuppressWarnings("java:S3077") + static class NonSerializableMemoizingSupplier implements Supplier + { + private final ReentrantLock lock = new ReentrantLock(); + + @SuppressWarnings("UnnecessaryLambda") // Must be a fixed singleton object + private static final Supplier SUCCESSFULLY_COMPUTED = + () -> { + throw new IllegalStateException(); // Should never get called. + }; + + @SuppressWarnings("PMD.AvoidUsingVolatile") + private volatile Supplier delegate; + // "value" does not need to be volatile; visibility piggy-backs on volatile read of "delegate". + private T value; + + NonSerializableMemoizingSupplier(final Supplier delegate) + { + this.delegate = Objects.requireNonNull(delegate); + } + + @Override + @SuppressWarnings("unchecked") // Cast from Supplier to Supplier is always valid + public T get() + { + // Because Supplier is read-heavy, we use the "double-checked locking" pattern. + if(this.delegate != SUCCESSFULLY_COMPUTED) + { + this.lock.lock(); + try + { + if(this.delegate != SUCCESSFULLY_COMPUTED) + { + final T t = this.delegate.get(); + this.value = t; + this.delegate = (Supplier)SUCCESSFULLY_COMPUTED; + return t; + } + } + finally + { + this.lock.unlock(); + } + } + // This is safe because we checked `delegate`. + return this.value; + } + + @Override + public String toString() + { + final Supplier currentDelegate = this.delegate; + return "Suppliers.memoize(" + + (currentDelegate == SUCCESSFULLY_COMPUTED + ? "" + : currentDelegate) + + ")"; + } + } +} diff --git a/base/src/main/java/software/xdev/tci/config/DefaultConfig.java b/base/src/main/java/software/xdev/tci/config/DefaultConfig.java index e8d3dcd0..38cf75d7 100644 --- a/base/src/main/java/software/xdev/tci/config/DefaultConfig.java +++ b/base/src/main/java/software/xdev/tci/config/DefaultConfig.java @@ -20,6 +20,8 @@ import java.util.function.IntSupplier; import java.util.function.LongSupplier; +import org.slf4j.LoggerFactory; + public abstract class DefaultConfig { @@ -27,7 +29,11 @@ public abstract class DefaultConfig protected Optional resolve(final String propertyName) { - final String fullPropertyName = this.propertyNamePrefix() + "." + propertyName; + return this.resolveFullyBuildPropertyName(this.propertyNamePrefix() + "." + propertyName); + } + + protected Optional resolveFullyBuildPropertyName(final String fullPropertyName) + { return Optional.ofNullable(System.getenv(fullPropertyName .replace(".", "_") .toUpperCase(Locale.ROOT))) @@ -36,11 +42,16 @@ protected Optional resolve(final String propertyName) protected boolean resolveBool(final String propertyName, final boolean defaultVal) { - return this.resolve(propertyName) - .map(s -> "1".equals(s) || Boolean.parseBoolean(s)) + return this.resolveBool(propertyName) .orElse(defaultVal); } + protected Optional resolveBool(final String propertyName) + { + return this.resolve(propertyName) + .map(s -> "1".equals(s) || Boolean.parseBoolean(s)); + } + protected int resolveInt(final String propertyName, final int defaultVal) { return this.resolveInt(propertyName, () -> defaultVal); @@ -77,4 +88,18 @@ protected long resolveLong(final String propertyName, final LongSupplier default }) .orElseGet(defaultValueSupplier::getAsLong); } + + protected T reportLegacyConfigOption(final String legacy, final String upToDate, final T value) + { + this.reportLegacyConfigOption(legacy, upToDate); + return value; + } + + protected void reportLegacyConfigOption(final String legacy, final String upToDate) + { + LoggerFactory.getLogger(this.getClass()).warn( + "Detected deprecated config option that will be removed: {} - use {} instead", + legacy, + upToDate); + } } diff --git a/base/src/main/java/software/xdev/tci/factory/prestart/PreStartableTCIFactory.java b/base/src/main/java/software/xdev/tci/factory/prestart/PreStartableTCIFactory.java index ea2bc211..b9329f28 100644 --- a/base/src/main/java/software/xdev/tci/factory/prestart/PreStartableTCIFactory.java +++ b/base/src/main/java/software/xdev/tci/factory/prestart/PreStartableTCIFactory.java @@ -150,7 +150,7 @@ public class PreStartableTCIFactory, I extends TCI /** * Has the following effects: *

    - *
  • true (default) - Directly attaches the Container to the network during startup if + *
  • true (default) - Directly attaches the container to the network during startup if * possible
  • *
  • false - Performs a Network#connect as if PreStarting is active. * This is slower however it emulates PreStarting better and may help finding bugs.
  • @@ -484,7 +484,7 @@ protected boolean isPreStartingDisabled() @Override public void close() { - this.log().warn("[{}] Shutting down", this.name); + this.log().info("[{}] Shutting down", this.name); if(!this.isPreStartingDisabled()) { GlobalPreStartCoordinator.instance().unregister(this); diff --git a/base/src/main/java/software/xdev/tci/factory/prestart/config/DefaultPreStartConfig.java b/base/src/main/java/software/xdev/tci/factory/prestart/config/DefaultPreStartConfig.java index 76f45e6e..ae19b745 100644 --- a/base/src/main/java/software/xdev/tci/factory/prestart/config/DefaultPreStartConfig.java +++ b/base/src/main/java/software/xdev/tci/factory/prestart/config/DefaultPreStartConfig.java @@ -25,8 +25,8 @@ *

    * Properties can be defined in the following way: *

    - * -Dinfra-pre-start.keep-ready=2
    - * -Dinfra-pre-start.coordinator.idle-cpu-percent=50
    + * -Dtci.infra-pre-start.keep-ready=2
    + * -Dtci.infra-pre-start.coordinator.idle-cpu-percent=50
      * 
    *

    */ @@ -90,7 +90,20 @@ public DefaultPreStartConfig() @Override public String propertyNamePrefix() { - return "infra-pre-start"; + return "tci.infra-pre-start"; + } + + @Override + protected Optional resolve(final String propertyName) + { + return super.resolve(propertyName) + .or(() -> this.resolveFullyBuildPropertyName("infra-prestart." + propertyName) + .map(v -> this.reportLegacyConfigOption( + "infra-prestart." + propertyName, + this.propertyNamePrefix() + "." + propertyName, + v) + ) + ); } @Override diff --git a/base/src/main/java/software/xdev/tci/factory/registry/DefaultTCIFactoryRegistry.java b/base/src/main/java/software/xdev/tci/factory/registry/DefaultTCIFactoryRegistry.java index c5380c44..95b0658f 100644 --- a/base/src/main/java/software/xdev/tci/factory/registry/DefaultTCIFactoryRegistry.java +++ b/base/src/main/java/software/xdev/tci/factory/registry/DefaultTCIFactoryRegistry.java @@ -21,6 +21,9 @@ import java.util.Set; import java.util.WeakHashMap; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.function.Function; import java.util.stream.Collectors; @@ -58,7 +61,28 @@ public void warmUp() .filter(f -> !this.warmedUpFactories.contains(f)) .map(this::warmUpFactory) .toList() - .forEach(CompletableFuture::join); + .forEach(this::waitForWarmUpCF); + } + } + + protected void waitForWarmUpCF(final CompletableFuture cf) + { + try + { + cf.get(10, TimeUnit.MINUTES); + } + catch(final TimeoutException ex) + { + throw new IllegalStateException("Timed out", ex); + } + catch(final InterruptedException ex) + { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Got interrupted", ex); + } + catch(final ExecutionException ex) + { + throw new IllegalStateException("Execution failed", ex); } } diff --git a/base/src/main/java/software/xdev/tci/leakdetection/config/DefaultLeakDetectionConfig.java b/base/src/main/java/software/xdev/tci/leakdetection/config/DefaultLeakDetectionConfig.java index a6664d55..c827642f 100644 --- a/base/src/main/java/software/xdev/tci/leakdetection/config/DefaultLeakDetectionConfig.java +++ b/base/src/main/java/software/xdev/tci/leakdetection/config/DefaultLeakDetectionConfig.java @@ -35,7 +35,7 @@ public DefaultLeakDetectionConfig() @Override protected String propertyNamePrefix() { - return "leak-detection"; + return "tci.leak-detection"; } @Override diff --git a/base/src/main/java/software/xdev/tci/safestart/SafeNamedContainerStarter.java b/base/src/main/java/software/xdev/tci/safestart/SafeNamedContainerStarter.java index 6f800b68..36d8d753 100644 --- a/base/src/main/java/software/xdev/tci/safestart/SafeNamedContainerStarter.java +++ b/base/src/main/java/software/xdev/tci/safestart/SafeNamedContainerStarter.java @@ -30,6 +30,8 @@ import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.GenericContainer; +import com.github.dockerjava.api.exception.NotFoundException; + import software.xdev.tci.envperf.EnvironmentPerformance; @@ -144,6 +146,15 @@ protected void tryCleanupContainerAfterStartFail(final List containerNam } catch(final Exception ex) { + // Check if container was already cleaned up (e.g. with tryStart->stop) + if(ex.getCause() instanceof final NotFoundException nfe + && nfe.getMessage() != null + && nfe.getMessage().startsWith("Status 404: {\"message\":\"No such container:")) + { + LOG.info("Container[name='{}'] was already removed", containerName); + return; + } + LOG.warn("Unable to cleanup container[name='{}']", containerName, ex); } } diff --git a/base/src/main/java/software/xdev/tci/serviceloading/TCIServiceLoader.java b/base/src/main/java/software/xdev/tci/serviceloading/TCIServiceLoader.java index 891a9156..45c8047f 100644 --- a/base/src/main/java/software/xdev/tci/serviceloading/TCIServiceLoader.java +++ b/base/src/main/java/software/xdev/tci/serviceloading/TCIServiceLoader.java @@ -24,6 +24,9 @@ import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + /** * Central point for service loading @@ -31,6 +34,8 @@ @SuppressWarnings({"java:S6548", "java:S2789"}) // Don't force us to write our own Optional!?! public class TCIServiceLoader { + private static final Logger LOG = LoggerFactory.getLogger(TCIServiceLoader.class); + protected final Object globalInitServiceLockHandlerLock = new Object(); protected final InheritableThreadLocal>> tlDetectRecursiveInitServices = new InheritableThreadLocal<>(); @@ -84,9 +89,12 @@ protected void initService(final Class clazz) lock = this.servicesLoadingSyncLocks.computeIfAbsent( clazz, ignored -> new ReentrantLock()); - lock.lock(); + this.reportLockAction("got", clazz); } + lock.lock(); + this.reportLockAction("acquired", clazz); + try { // Already initialized? @@ -97,6 +105,11 @@ protected void initService(final Class clazz) this.loadedServices.put(clazz, this.loadService(clazz)); } + catch(final RuntimeException rex) + { + LOG.debug("Service loading failed", rex); + throw rex; + } finally { synchronized(this.globalInitServiceLockHandlerLock) @@ -106,10 +119,24 @@ protected void initService(final Class clazz) this.servicesLoadingSyncLocks.remove(clazz); lock.unlock(); + this.reportLockAction("release", clazz); } } } + protected void reportLockAction(final String action, final Class clazz) + { + if(LOG.isTraceEnabled()) + { + LOG.trace( + "Lock {} {} - T:{}", + action, + clazz, + Thread.currentThread().getName(), + new RuntimeException()); + } + } + protected Optional loadService(final Class clazz) { return ServiceLoader.load(clazz) diff --git a/base/src/main/java/software/xdev/tci/startup/error/java/fatal/HsErrPidStartUpCrashReporter.java b/base/src/main/java/software/xdev/tci/startup/error/java/fatal/HsErrPidStartUpCrashReporter.java new file mode 100644 index 00000000..89fca3c7 --- /dev/null +++ b/base/src/main/java/software/xdev/tci/startup/error/java/fatal/HsErrPidStartUpCrashReporter.java @@ -0,0 +1,136 @@ +/* + * Copyright © 2024 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.startup.error.java.fatal; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.io.IOUtils; +import org.slf4j.Logger; +import org.testcontainers.containers.GenericContainer; + +import software.xdev.tci.concurrent.Suppliers; + + +/** + * Utility to log contents of java hs_err_pid files of a container that failed to start. + *

    + * Additionally also copies the file into target/has_err_pid/<containerId>/<hs_err_pid-file> + *

    + */ +public class HsErrPidStartUpCrashReporter +{ + protected static final String HS_ERR_PID = "hs_err_pid"; + protected static final Supplier DEFAULT_HS_ERR_PID_EXTRACTION_PATTERN_SUPPLIER = + Suppliers.memoize(() -> Pattern.compile("^# (\\S{1,999}hs_err_pid\\d{1,9}\\.log)$", Pattern.MULTILINE)); + + protected final GenericContainer container; + protected final String hsErrPidSearchKey; + protected final Supplier hsErrPidExtractionPattern; + protected Supplier hsErrPidPathSupplier = Suppliers.memoize( + () -> Paths.get("target/has_err_pid")); + + protected boolean startedWithoutCrashing; + + public HsErrPidStartUpCrashReporter(final GenericContainer container) + { + this(container, HS_ERR_PID, DEFAULT_HS_ERR_PID_EXTRACTION_PATTERN_SUPPLIER); + } + + protected HsErrPidStartUpCrashReporter( + final GenericContainer container, + final String hsErrPidSearchKey, + final Supplier hsErrPidExtractionPattern) + { + this.container = container; + this.hsErrPidSearchKey = hsErrPidSearchKey; + this.hsErrPidExtractionPattern = hsErrPidExtractionPattern; + } + + public void containerIsStarted() + { + this.startedWithoutCrashing = true; + } + + public void containerIsStopping(final Logger logger) + { + if(this.startedWithoutCrashing || !logger.isWarnEnabled()) + { + return; + } + + final String logs = this.container.getLogs(); + if(!logs.contains(this.hsErrPidSearchKey)) + { + return; + } + + final Matcher matcher = this.hsErrPidExtractionPattern.get().matcher(logs); + if(!matcher.find()) + { + logger.warn( + "Detected {} in log output but was unable to determine the file location - " + + "No regex match", this.hsErrPidSearchKey); + return; + } + + final String filePath = matcher.group(1); + if(filePath == null || filePath.isBlank()) + { + logger.warn( + "Detected {} in log output but was unable to determine the file location - " + + "No regex group match", this.hsErrPidSearchKey); + return; + } + + try + { + final String containerId = this.container.getContainerId(); + + final Path hsErrPidContainerDirPath = this.hsErrPidPathSupplier.get().resolve(containerId); + Files.createDirectories(hsErrPidContainerDirPath); + final Path targetHsErrPidFilePath = + hsErrPidContainerDirPath.resolve(Path.of(filePath).getFileName().toString()); + + this.container.copyFileFromContainer( + filePath, + is -> Files.copy( + is, + targetHsErrPidFilePath, + StandardCopyOption.REPLACE_EXISTING)); + + logger.warn( + "Detected {} file[container={}]: {} -> {} \n{}", + this.hsErrPidSearchKey, + containerId, + filePath, + targetHsErrPidFilePath, + this.container.copyFileFromContainer( + filePath, + is -> IOUtils.toString(is, StandardCharsets.UTF_8))); + } + catch(final Exception ex) + { + logger.warn("Failed to read {} file {}", this.hsErrPidSearchKey, filePath, ex); + } + } +} diff --git a/base/src/main/java/software/xdev/tci/startup/wait/AbortMonitor.java b/base/src/main/java/software/xdev/tci/startup/wait/AbortMonitor.java new file mode 100644 index 00000000..08723082 --- /dev/null +++ b/base/src/main/java/software/xdev/tci/startup/wait/AbortMonitor.java @@ -0,0 +1,45 @@ +/* + * Copyright © 2024 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.startup.wait; + +import java.util.concurrent.atomic.AtomicReference; + + +/** + * Monitor that holds the state if a WaitStrategy should be aborted + */ +public class AbortMonitor +{ + private final AtomicReference abortEx = new AtomicReference<>(); + + public void trigger(final RuntimeException rex) + { + this.abortEx.compareAndSet(null, rex); + } + + public void throwIfRequired() + { + if(this.shouldAbort()) + { + throw this.abortEx.get(); + } + } + + public boolean shouldAbort() + { + return this.abortEx.get() != null; + } +} diff --git a/base/src/main/java/software/xdev/tci/startup/wait/FastAbortOnContainerDeathWaitStrategy.java b/base/src/main/java/software/xdev/tci/startup/wait/FastAbortOnContainerDeathWaitStrategy.java new file mode 100644 index 00000000..694be3e7 --- /dev/null +++ b/base/src/main/java/software/xdev/tci/startup/wait/FastAbortOnContainerDeathWaitStrategy.java @@ -0,0 +1,166 @@ +/* + * Copyright © 2024 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.startup.wait; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; + +import org.rnorth.ducttape.ratelimits.RateLimiter; +import org.rnorth.ducttape.ratelimits.RateLimiterBuilder; +import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategy; + +import software.xdev.tci.concurrent.ExecutorServiceCreatorHolder; +import software.xdev.tci.startup.wait.holder.AbortableStrategyValues; +import software.xdev.tci.startup.wait.holder.AbortableStrategyValuesHolder; +import software.xdev.tci.startup.wait.strategy.AbstractWaitAbortableStrategy; +import software.xdev.tci.startup.wait.strategy.WaitAllAbortableStrategy; + + +/** + * A "wrapper" strategy that causes a fast-abort when the container dies. + *

    + * This is helpful when the container dies during the startup phase as the default wait-strategies + * would wait for the timeout to expire which can take up to a few minutes. + *

    + */ +public class FastAbortOnContainerDeathWaitStrategy extends AbstractWaitStrategy +{ + protected static final ExecutorService DEFAULT_EXECUTOR = + ExecutorServiceCreatorHolder.instance().createUnlimited("TCI-testcontainers-wait"); + + protected static final RateLimiter DEFAULT_RATE_LIMITER = RateLimiterBuilder + .newBuilder() + .withRate(10, TimeUnit.MINUTES) + .withConstantThroughput() + .build(); + + protected final WaitStrategy waitStrategy; + protected final ExecutorService executor; + + public FastAbortOnContainerDeathWaitStrategy(final AbstractWaitAbortableStrategy waitStrategy) + { + this((WaitStrategy)waitStrategy); + } + + /** + * @apiNote It's recommended to use {@link #FastAbortOnContainerDeathWaitStrategy(AbstractWaitAbortableStrategy)} + */ + public FastAbortOnContainerDeathWaitStrategy(final WaitStrategy waitStrategy) + { + this(waitStrategy, DEFAULT_EXECUTOR); + } + + protected FastAbortOnContainerDeathWaitStrategy( + final WaitStrategy waitStrategy, + final ExecutorService executor) + { + this.waitStrategy = waitStrategy; + this.executor = executor; + + this.withRateLimiter(DEFAULT_RATE_LIMITER); + } + + @Override + protected void waitUntilReady() + { + final AbortMonitor abortMonitor = new AbortMonitor(); + final CompletableFuture cfWaitStrategy = CompletableFuture.runAsync( + () -> AbortableStrategyValuesHolder.executeWith( + () -> this.waitStrategy.waitUntilReady(this.waitStrategyTarget), + new AbortableStrategyValues(abortMonitor, this.executor)), + this.executor + ); + + final CompletableFuture cfContainerDeathWatchDog = CompletableFuture.runAsync( + () -> this.runCheckIfContainerIsDead(abortMonitor, cfWaitStrategy), + this.executor + ); + + try + { + CompletableFuture.anyOf(cfWaitStrategy, cfContainerDeathWatchDog).join(); + } + finally + { + abortMonitor.trigger(new CancellationException("Completed")); + } + } + + @SuppressWarnings("java:S5411") // Irrelevant here! + protected void runCheckIfContainerIsDead( + final AbortMonitor abortMonitor, + final CompletableFuture cfWaitStrategy) + { + while(!abortMonitor.shouldAbort() && !cfWaitStrategy.isDone()) + { + if(!this.rateLimiterWhenReadyExceptionless(this::isContainerAlive)) + { + final IllegalStateException abortEx = new IllegalStateException( + "Container " + this.waitStrategyTarget.getContainerId() + " is dead - Aborting"); + abortMonitor.trigger(abortEx); + throw abortEx; + } + } + } + + @SuppressWarnings("PMD.PreserveStackTrace") + protected T rateLimiterWhenReadyExceptionless(final Supplier supplier) + { + try + { + return this.getRateLimiter().getWhenReady(supplier::get); + } + catch(final Exception ex) + { + if(ex instanceof final RuntimeException rex) + { + throw rex; + } + throw new RuntimeException(ex); + } + } + + protected boolean isContainerAlive() + { + return Optional.ofNullable(this.waitStrategyTarget.getCurrentContainerInfo().getState()) + .filter(s -> Boolean.TRUE.equals(s.getRunning()) + || Boolean.TRUE.equals(s.getPaused())) + .isPresent(); + } + + @Override + public WaitStrategy withStartupTimeout(final Duration startupTimeout) + { + this.waitStrategy.withStartupTimeout(startupTimeout); + return this; + } + + /** + * Creates a new instance with {@link WaitAllAbortableStrategy} as inner strategy and allows configuring it. + */ + public static FastAbortOnContainerDeathWaitStrategy waitAll( + final UnaryOperator configure) + { + return new FastAbortOnContainerDeathWaitStrategy(configure.apply(new WaitAllAbortableStrategy())); + } +} diff --git a/base/src/main/java/software/xdev/tci/startup/wait/holder/AbortableStrategyValues.java b/base/src/main/java/software/xdev/tci/startup/wait/holder/AbortableStrategyValues.java new file mode 100644 index 00000000..97f5f2a3 --- /dev/null +++ b/base/src/main/java/software/xdev/tci/startup/wait/holder/AbortableStrategyValues.java @@ -0,0 +1,28 @@ +/* + * Copyright © 2024 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.startup.wait.holder; + +import java.util.concurrent.ExecutorService; + +import software.xdev.tci.startup.wait.AbortMonitor; + + +public record AbortableStrategyValues( + AbortMonitor abortMonitor, + ExecutorService executor +) +{ +} diff --git a/base/src/main/java/software/xdev/tci/startup/wait/holder/AbortableStrategyValuesHolder.java b/base/src/main/java/software/xdev/tci/startup/wait/holder/AbortableStrategyValuesHolder.java new file mode 100644 index 00000000..92f57a23 --- /dev/null +++ b/base/src/main/java/software/xdev/tci/startup/wait/holder/AbortableStrategyValuesHolder.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2024 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.startup.wait.holder; + +/** + * @apiNote Please note that this is the Java 21 version. The Java 25 version uses ScopedValues. + */ +public final class AbortableStrategyValuesHolder +{ + private static final ThreadLocal TL = new ThreadLocal<>(); + + public static void executeWith(final Runnable runnable, final AbortableStrategyValues values) + { + TL.set(values); + try + { + runnable.run(); + } + finally + { + TL.remove(); + } + } + + public static AbortableStrategyValues get() + { + return TL.get(); + } + + private AbortableStrategyValuesHolder() + { + } +} diff --git a/base/src/main/java/software/xdev/tci/startup/wait/strategy/AbstractWaitAbortableStrategy.java b/base/src/main/java/software/xdev/tci/startup/wait/strategy/AbstractWaitAbortableStrategy.java new file mode 100644 index 00000000..5dd0be68 --- /dev/null +++ b/base/src/main/java/software/xdev/tci/startup/wait/strategy/AbstractWaitAbortableStrategy.java @@ -0,0 +1,75 @@ +/* + * Copyright © 2024 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.startup.wait.strategy; + +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; + +import org.rnorth.ducttape.unreliables.Unreliables; +import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; + +import software.xdev.tci.startup.wait.AbortMonitor; +import software.xdev.tci.startup.wait.holder.AbortableStrategyValuesHolder; + + +public abstract class AbstractWaitAbortableStrategy> + extends AbstractWaitStrategy +{ + protected T startupRetryUntilSuccess(final Callable callable) + { + return Unreliables.retryUntilSuccess( + (int)this.startupTimeout.getSeconds(), + TimeUnit.SECONDS, + callable + ); + } + + protected void startupRetryUntilSuccessWithRateLimitWhenNotAborted( + final AbortMonitor abortMonitor, + final Runnable runnable) + { + this.startupRetryUntilSuccess(() -> { + if(!abortMonitor.shouldAbort()) + { + this.getRateLimiter().doWhenReady(runnable); + } + return true; + }); + } + + @Override + protected void waitUntilReady() + { + this.waitUntilReady(AbortableStrategyValuesHolder.get().abortMonitor()); + } + + protected void waitUntilReady(final AbortMonitor abortMonitor) + { + // NOOP + } + + protected ExecutorService getExecutor() + { + return AbortableStrategyValuesHolder.get().executor(); + } + + @SuppressWarnings("unchecked") + protected S self() + { + return (S)this; + } +} diff --git a/base/src/main/java/software/xdev/tci/startup/wait/strategy/HostPortWaitAbortableStrategy.java b/base/src/main/java/software/xdev/tci/startup/wait/strategy/HostPortWaitAbortableStrategy.java new file mode 100644 index 00000000..49b79e61 --- /dev/null +++ b/base/src/main/java/software/xdev/tci/startup/wait/strategy/HostPortWaitAbortableStrategy.java @@ -0,0 +1,184 @@ +/* + * Copyright © 2024 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.startup.wait.strategy; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.wait.internal.ExternalPortListeningCheck; +import org.testcontainers.containers.wait.internal.InternalCommandPortListeningCheck; +import org.testcontainers.shaded.org.awaitility.Awaitility; + +import software.xdev.tci.startup.wait.AbortMonitor; + + +/** + * Based on {@link org.testcontainers.containers.wait.strategy.HostPortWaitStrategy} + */ +public class HostPortWaitAbortableStrategy extends AbstractWaitAbortableStrategy +{ + private static final Logger LOG = LoggerFactory.getLogger(HostPortWaitAbortableStrategy.class); + + protected int[] ports; + + @Override + protected void waitUntilReady(final AbortMonitor abortMonitor) + { + final Set externalLivenessCheckPorts = this.determineExternalLivenessCheckPorts(); + if(externalLivenessCheckPorts == null || externalLivenessCheckPorts.isEmpty()) + { + if(LOG.isDebugEnabled()) + { + LOG.debug( + "Liveness check ports of {} is empty. Not waiting.", + this.waitStrategyTarget.getContainerInfo().getName() + ); + } + return; + } + + abortMonitor.throwIfRequired(); + + final Set internalPorts = this.getInternalPorts( + externalLivenessCheckPorts, + this.waitStrategyTarget.getExposedPorts()); + + final Callable internalCheck = this.createInternalCheck(internalPorts); + final Callable externalCheck = this.createExternalCheck(externalLivenessCheckPorts); + + try + { + final List> futures = this.getExecutor().invokeAll( + Arrays.asList( + // Blocking + () -> { + final long startMs = System.currentTimeMillis(); + final Boolean result = internalCheck.call(); + LOG.debug( + "Internal port check {} for {} took {}ms", + Boolean.TRUE.equals(result) ? "passed" : "failed", + internalPorts, + System.currentTimeMillis() - startMs + ); + return result; + }, + // Polling + () -> { + final long startMs = System.currentTimeMillis(); + Awaitility.await() + .pollInSameThread() + .pollInterval(Duration.ofMillis(100)) + .pollDelay(Duration.ZERO) + .failFast( + "container is no longer running", + () -> !this.waitStrategyTarget.isRunning() || abortMonitor.shouldAbort()) + .ignoreExceptions() + .forever() + .until(externalCheck); + + LOG.debug( + "External port check passed for {} mapped as {} took {}ms", + internalPorts, + externalLivenessCheckPorts, + System.currentTimeMillis() - startMs + ); + return true; + } + ), + this.startupTimeout.getSeconds(), + TimeUnit.SECONDS + ); + + for(final Future future : futures) + { + future.get(0, TimeUnit.SECONDS); + } + } + catch(final InterruptedException iex) + { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Got interrupted", iex); + } + catch(final CancellationException | ExecutionException | TimeoutException e) + { + throw new ContainerLaunchException( + "Timed out waiting for container port to open (" + + this.waitStrategyTarget.getHost() + + " ports: " + + externalLivenessCheckPorts + + " should be listening)", + e + ); + } + } + + private ExternalPortListeningCheck createExternalCheck(final Set externalLivenessCheckPorts) + { + return new ExternalPortListeningCheck( + this.waitStrategyTarget, + externalLivenessCheckPorts + ); + } + + private InternalCommandPortListeningCheck createInternalCheck(final Set internalPorts) + { + return new InternalCommandPortListeningCheck(this.waitStrategyTarget, internalPorts); + } + + /** + * @apiNote Can return null + */ + protected Set determineExternalLivenessCheckPorts() + { + if(this.ports == null || this.ports.length == 0) + { + return this.getLivenessCheckPorts(); + } + + return Arrays + .stream(this.ports) + .mapToObj(this.waitStrategyTarget::getMappedPort) + .collect(Collectors.toSet()); + } + + protected Set getInternalPorts( + final Set externalLivenessCheckPorts, + final List exposedPorts) + { + return exposedPorts + .stream() + .filter(port -> externalLivenessCheckPorts.contains(this.waitStrategyTarget.getMappedPort(port))) + .collect(Collectors.toSet()); + } + + public HostPortWaitAbortableStrategy forPorts(final int... ports) + { + this.ports = ports; + return this.self(); + } +} diff --git a/base/src/main/java/software/xdev/tci/startup/wait/strategy/HttpWaitAbortableStrategy.java b/base/src/main/java/software/xdev/tci/startup/wait/strategy/HttpWaitAbortableStrategy.java new file mode 100644 index 00000000..42e951ad --- /dev/null +++ b/base/src/main/java/software/xdev/tci/startup/wait/strategy/HttpWaitAbortableStrategy.java @@ -0,0 +1,524 @@ +/* + * Copyright © 2024 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.startup.wait.strategy; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.net.HttpURLConnection; +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.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 org.rnorth.ducttape.TimeoutException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.shaded.com.google.common.io.BaseEncoding; + +import software.xdev.tci.startup.wait.AbortMonitor; + + +/** + * Based on {@link org.testcontainers.containers.wait.strategy.HttpWaitStrategy} + */ +@SuppressWarnings("PMD.GodClass") +public class HttpWaitAbortableStrategy extends AbstractWaitAbortableStrategy +{ + private static final Logger LOG = LoggerFactory.getLogger(HttpWaitAbortableStrategy.class); + + /** + * Authorization HTTP header. + */ + protected static final String HEADER_AUTHORIZATION = "Authorization"; + + /** + * Basic Authorization scheme prefix. + */ + protected static final String AUTH_BASIC = "Basic "; + + protected String path = "/"; + + protected String method = "GET"; + + protected final Set statusCodes = new HashSet<>(); + + protected boolean tlsEnabled; + + protected String username; + + protected String password; + + protected final Map headers = new HashMap<>(); + + protected Predicate responsePredicate; + + protected Predicate statusCodePredicate; + + protected Optional livenessPort = Optional.empty(); + + protected Duration readTimeout = Duration.ofSeconds(1); + + protected boolean allowInsecure; + + // region Configure + + /** + * Waits for the given status code. + * + * @param statusCode the expected status code + * @return this + */ + public HttpWaitAbortableStrategy forStatusCode(final int statusCode) + { + this.statusCodes.add(statusCode); + return this.self(); + } + + /** + * Waits for the status code to pass the given predicate + * + * @param statusCodePredicate The predicate to test the response against + * @return this + */ + public HttpWaitAbortableStrategy forStatusCodeMatching(final Predicate statusCodePredicate) + { + this.statusCodePredicate = statusCodePredicate; + return this; + } + + /** + * Waits for the given path. + * + * @param path the path to check + * @return this + */ + public HttpWaitAbortableStrategy forPath(final String path) + { + this.path = path; + return this.self(); + } + + /** + * Wait for the given port. + * + * @param port the given port + * @return this + */ + public HttpWaitAbortableStrategy forPort(final int port) + { + this.livenessPort = Optional.of(port); + return this.self(); + } + + /** + * Indicates that the status check should use HTTPS. + * + * @return this + */ + public HttpWaitAbortableStrategy usingTls() + { + this.tlsEnabled = true; + return this.self(); + } + + /** + * Indicates the HTTP method to use (GET by default). + * + * @param method the HTTP method. + * @return this + */ + public HttpWaitAbortableStrategy withMethod(final String method) + { + this.method = method; + return this.self(); + } + + /** + * Indicates that HTTPS connection could use untrusted (self signed) certificate chains. + * + * @return this + */ + public HttpWaitAbortableStrategy allowInsecure() + { + this.allowInsecure = true; + return this.self(); + } + + /** + * Authenticate with HTTP Basic Authorization credentials. + * + * @param username the username + * @param password the password + * @return this + */ + public HttpWaitAbortableStrategy withBasicCredentials( + final String username, + final String password) + { + this.username = username; + this.password = password; + return this.self(); + } + + /** + * Add a custom HTTP Header to the call. + * + * @param name The HTTP Header name + * @param value The HTTP Header value + * @return this + */ + public HttpWaitAbortableStrategy withHeader( + final String name, + final String value) + { + this.headers.put(name, value); + return this.self(); + } + + /** + * Add multiple custom HTTP Headers to the call. + * + * @param headers Headers map of name/value + * @return this + */ + public HttpWaitAbortableStrategy withHeaders(final Map headers) + { + this.headers.putAll(headers); + return this.self(); + } + + /** + * Set the HTTP connections read timeout. + * + * @param timeout the timeout (minimum 1 millisecond) + * @return this + */ + public HttpWaitAbortableStrategy withReadTimeout(final Duration timeout) + { + if(timeout.toMillis() < 1) + { + throw new IllegalArgumentException("you cannot specify a value smaller than 1 ms"); + } + this.readTimeout = timeout; + return this.self(); + } + + /** + * Waits for the response to pass the given predicate + * + * @param responsePredicate The predicate to test the response against + * @return this + */ + public HttpWaitAbortableStrategy forResponsePredicate(final Predicate responsePredicate) + { + this.responsePredicate = responsePredicate; + return this.self(); + } + + // endregion + + @Override + protected void waitUntilReady(final AbortMonitor abortMonitor) + { + final String containerName = this.waitStrategyTarget.getContainerInfo().getName(); + + final Integer livenessCheckPort = this.livenessPort + .map(this.waitStrategyTarget::getMappedPort) + .orElseGet(() -> { + final Set livenessCheckPorts = this.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(); + }); + + abortMonitor.throwIfRequired(); + + if(null == livenessCheckPort || -1 == livenessCheckPort) + { + return; + } + final URI rawUri = this.buildLivenessUri(livenessCheckPort); + final String uri = rawUri.toString(); + + try + { + // Un-map the port for logging + final int originalPort = this.waitStrategyTarget + .getExposedPorts() + .stream() + .filter(exposedPort -> rawUri.getPort() == this.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, + this.startupTimeout.getSeconds(), + uri, + rawUri.getPort(), + originalPort + ); + } + catch(final 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); + } + + abortMonitor.throwIfRequired(); + + // try to connect to the URL + try + { + this.startupRetryUntilSuccessWithRateLimitWhenNotAborted( + abortMonitor, + () -> this.connectAndCheck(uri)); + abortMonitor.throwIfRequired(); + } + catch(final TimeoutException e) + { + throw new ContainerLaunchException( + String.format( + "Timed out waiting for URL to be accessible (%s should return HTTP %s)", + uri, + this.statusCodes.isEmpty() ? HttpURLConnection.HTTP_OK : this.statusCodes + ), + e + ); + } + } + + protected void connectAndCheck(final String uri) + { + try + { + final HttpURLConnection connection = this.openConnection(uri); + connection.setReadTimeout(Math.toIntExact(this.readTimeout.toMillis())); + + // authenticate + if(this.username != null && !this.username.isEmpty()) + { + connection.setRequestProperty( + HEADER_AUTHORIZATION, + this.buildAuthString(this.username, this.password) + ); + connection.setUseCaches(false); + } + + // Add user configured headers + this.headers.forEach(connection::setRequestProperty); + connection.setRequestMethod(this.method); + connection.connect(); + + LOG.trace("Get response code {}", connection.getResponseCode()); + + // Choose the statusCodePredicate strategy depending on what we defined. + if(!this.determineStatusCodePredicate().test(connection.getResponseCode())) + { + throw new IllegalStateException("HTTP response code was: " + connection.getResponseCode()); + } + + if(this.responsePredicate != null) + { + final String responseBody = this.getResponseBody(connection); + + LOG.trace("Got response {}", responseBody); + + if(!this.responsePredicate.test(responseBody)) + { + throw new IllegalStateException("Response: " + responseBody + " did not match predicate"); + } + } + } + catch(final IOException e) + { + throw new UncheckedIOException(e); + } + } + + private Predicate determineStatusCodePredicate() + { + if(this.statusCodes.isEmpty() && this.statusCodePredicate == null) + { + // We have no status code and no predicate so we expect a 200 OK response code + return responseCode -> HttpURLConnection.HTTP_OK == responseCode; + } + else if(!this.statusCodes.isEmpty() && this.statusCodePredicate == null) + { + // We use the default status predicate checker when we only have status codes + return this.statusCodes::contains; + } + else if(this.statusCodes.isEmpty()) + { + // We only have a predicate + return this.statusCodePredicate; + } + + // We have both predicate and status code + return this.statusCodePredicate.or(this.statusCodes::contains); + } + + protected HttpURLConnection openConnection(final String uri) throws IOException + { + if(!this.tlsEnabled) + { + return (HttpURLConnection)new URL(uri).openConnection(); + } + + final HttpsURLConnection connection = (HttpsURLConnection)new URL(uri).openConnection(); + if(this.allowInsecure) + { + // Create a trust manager that does not validate certificate chains + // and trust all certificates + final TrustManager[] trustAllCerts = new TrustManager[]{ + new X509ExtendedTrustManager() + { + @Override + public X509Certificate[] getAcceptedIssuers() + { + return new X509Certificate[0]; + } + + @Override + public void checkClientTrusted(final X509Certificate[] certs, final String authType) + { + } + + @Override + public void checkServerTrusted(final X509Certificate[] certs, final String authType) + { + } + + @Override + public void checkClientTrusted( + final X509Certificate[] chain, + final String authType, + final Socket socket) + { + } + + @Override + public void checkServerTrusted( + final X509Certificate[] chain, + final String authType, + final Socket socket) + { + } + + @Override + public void checkClientTrusted( + final X509Certificate[] chain, + final String authType, + final SSLEngine engine) + { + } + + @Override + public void checkServerTrusted( + final X509Certificate[] chain, + final String authType, + final 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; + } + + /** + * Build the URI on which to check if the container is ready. + * + * @param livenessCheckPort the liveness port + * @return the liveness URI + */ + @SuppressWarnings("checkstyle:MagicNumber") + protected URI buildLivenessUri(final int livenessCheckPort) + { + final String scheme = (this.tlsEnabled ? "https" : "http") + "://"; + final String host = this.waitStrategyTarget.getHost(); + + final String portSuffix = + this.tlsEnabled && livenessCheckPort == 443 + || !this.tlsEnabled && livenessCheckPort == 80 + ? "" + : ":" + livenessCheckPort; + + return URI.create(scheme + host + portSuffix + this.path); + } + + /** + * @param username the username + * @param password the password + * @return a basic authentication string for the given credentials + */ + protected String buildAuthString(final String username, final String password) + { + return AUTH_BASIC + BaseEncoding.base64().encode((username + ":" + password).getBytes()); + } + + @SuppressWarnings({"PMD.AvoidStringBuilderOrBuffer", "checkstyle:MagicNumber"}) + protected String getResponseBody(final HttpURLConnection connection) throws IOException + { + final BufferedReader reader = new BufferedReader(new InputStreamReader( + connection.getResponseCode() >= 200 && connection.getResponseCode() < 300 + ? connection.getInputStream() + : connection.getErrorStream())); + + final StringBuilder builder = new StringBuilder(); + String line; + while((line = reader.readLine()) != null) + { + builder.append(line); + } + return builder.toString(); + } +} diff --git a/base/src/main/java/software/xdev/tci/startup/wait/strategy/LogMessageWaitAbortableStrategy.java b/base/src/main/java/software/xdev/tci/startup/wait/strategy/LogMessageWaitAbortableStrategy.java new file mode 100644 index 00000000..651d6ee9 --- /dev/null +++ b/base/src/main/java/software/xdev/tci/startup/wait/strategy/LogMessageWaitAbortableStrategy.java @@ -0,0 +1,101 @@ +/* + * Copyright © 2024 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.startup.wait.strategy; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Predicate; + +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.output.FrameConsumerResultCallback; +import org.testcontainers.containers.output.OutputFrame; +import org.testcontainers.containers.output.WaitingConsumer; + +import com.github.dockerjava.api.command.LogContainerCmd; + +import software.xdev.tci.startup.wait.AbortMonitor; + + +/** + * Based on {@link org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy} + */ +public class LogMessageWaitAbortableStrategy extends AbstractWaitAbortableStrategy +{ + protected String regEx; + + protected int times = 1; + + @Override + protected void waitUntilReady(final AbortMonitor abortMonitor) + { + try(final LogContainerCmd cmd = this.waitStrategyTarget + .getDockerClient() + .logContainerCmd(this.waitStrategyTarget.getContainerId()) + .withFollowStream(true) + .withSince(0) + .withStdOut(true) + .withStdErr(true)) + { + try(final FrameConsumerResultCallback callback = new FrameConsumerResultCallback()) + { + final WaitingConsumer waitingConsumer = new WaitingConsumer(); + callback.addConsumer(OutputFrame.OutputType.STDOUT, waitingConsumer); + callback.addConsumer(OutputFrame.OutputType.STDERR, waitingConsumer); + + cmd.exec(callback); + + final Predicate waitPredicate = outputFrame -> { + abortMonitor.throwIfRequired(); + + // (?s) enables line terminator matching (equivalent to Pattern.DOTALL) + return outputFrame.getUtf8String().matches("(?s)" + this.regEx); + }; + try + { + waitingConsumer.waitUntil( + waitPredicate, + this.startupTimeout.getSeconds(), + TimeUnit.SECONDS, + this.times); + } + catch(final TimeoutException e) + { + throw new ContainerLaunchException( + "Timed out waiting for log output matching '" + this.regEx + "'", + e); + } + } + catch(final IOException ioe) + { + throw new UncheckedIOException(ioe); + } + } + } + + public LogMessageWaitAbortableStrategy withRegEx(final String regEx) + { + this.regEx = regEx; + return this.self(); + } + + public LogMessageWaitAbortableStrategy withTimes(final int times) + { + this.times = times; + return this.self(); + } +} diff --git a/base/src/main/java/software/xdev/tci/startup/wait/strategy/WaitAllAbortableStrategy.java b/base/src/main/java/software/xdev/tci/startup/wait/strategy/WaitAllAbortableStrategy.java new file mode 100644 index 00000000..eb69606b --- /dev/null +++ b/base/src/main/java/software/xdev/tci/startup/wait/strategy/WaitAllAbortableStrategy.java @@ -0,0 +1,132 @@ +/* + * Copyright © 2024 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.startup.wait.strategy; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.rnorth.ducttape.timeouts.Timeouts; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; +import org.testcontainers.containers.wait.strategy.WaitStrategy; + +import software.xdev.tci.startup.wait.AbortMonitor; +import software.xdev.tci.startup.wait.holder.AbortableStrategyValues; +import software.xdev.tci.startup.wait.holder.AbortableStrategyValuesHolder; + + +/** + * Based on {@link WaitAllStrategy} + */ +public class WaitAllAbortableStrategy extends AbstractWaitAbortableStrategy +{ + private static final Logger LOG = LoggerFactory.getLogger(WaitAllAbortableStrategy.class); + + protected final WaitAllStrategy.Mode mode; + + protected final List strategies = new ArrayList<>(); + + public WaitAllAbortableStrategy() + { + this(WaitAllStrategy.Mode.WITH_OUTER_TIMEOUT); + } + + public WaitAllAbortableStrategy(final WaitAllStrategy.Mode mode) + { + this.mode = mode; + } + + @Override + protected void waitUntilReady(final AbortMonitor abortMonitor) + { + if(this.mode == WaitAllStrategy.Mode.WITH_INDIVIDUAL_TIMEOUTS_ONLY) + { + this.waitUntilNestedStrategiesAreReady(abortMonitor); + } + else + { + final AbortableStrategyValues valuesToPropagate = AbortableStrategyValuesHolder.get(); + Timeouts.doWithTimeout( + (int)this.startupTimeout.toMillis(), + TimeUnit.MILLISECONDS, + () -> AbortableStrategyValuesHolder.executeWith( + () -> this.waitUntilNestedStrategiesAreReady(abortMonitor), + valuesToPropagate) + ); + } + } + + private void waitUntilNestedStrategiesAreReady(final AbortMonitor abortMonitor) + { + for(final WaitStrategy strategy : this.strategies) + { + abortMonitor.throwIfRequired(); + + final long startMs = System.currentTimeMillis(); + LOG.debug("Waiting for {}", strategy); + + strategy.waitUntilReady(this.waitStrategyTarget); + + LOG.debug("Finished waiting for {}, took {}ms", strategy, System.currentTimeMillis() - startMs); + } + } + + public WaitAllAbortableStrategy withStrategy(final AbstractWaitAbortableStrategy strategy) + { + return this.withWaitStrategy(strategy); + } + + /** + * @apiNote It's recommended to use {@link #withStrategy(AbstractWaitAbortableStrategy)} + */ + public WaitAllAbortableStrategy withWaitStrategy(final WaitStrategy strategy) + { + if(this.mode == WaitAllStrategy.Mode.WITH_OUTER_TIMEOUT) + { + this.applyStartupTimeout(strategy); + } + + this.strategies.add(strategy); + return this.self(); + } + + @Override + public WaitAllAbortableStrategy withStartupTimeout(final Duration startupTimeout) + { + if(this.mode == WaitAllStrategy.Mode.WITH_INDIVIDUAL_TIMEOUTS_ONLY) + { + throw new IllegalStateException( + String.format( + "Changing startup timeout is not supported with mode %s", + WaitAllStrategy.Mode.WITH_INDIVIDUAL_TIMEOUTS_ONLY + ) + ); + } + + super.withStartupTimeout(startupTimeout); + + this.strategies.forEach(this::applyStartupTimeout); + return this.self(); + } + + private void applyStartupTimeout(final WaitStrategy childStrategy) + { + childStrategy.withStartupTimeout(this.startupTimeout); + } +} diff --git a/base/src/main/java25/software/xdev/tci/startup/wait/holder/AbortableStrategyValuesHolder.java b/base/src/main/java25/software/xdev/tci/startup/wait/holder/AbortableStrategyValuesHolder.java new file mode 100644 index 00000000..d0c2b249 --- /dev/null +++ b/base/src/main/java25/software/xdev/tci/startup/wait/holder/AbortableStrategyValuesHolder.java @@ -0,0 +1,21 @@ +package software.xdev.tci.startup.wait.holder; + + +public final class AbortableStrategyValuesHolder +{ + private static final ScopedValue SV = ScopedValue.newInstance(); + + public static void executeWith(final Runnable runnable, final AbortableStrategyValues values) + { + ScopedValue.where(SV, values).run(runnable); + } + + public static AbortableStrategyValues get() + { + return SV.get(); + } + + private AbortableStrategyValuesHolder() + { + } +} diff --git a/bom/pom.xml b/bom/pom.xml index de9df7dc..e373dffe 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -6,7 +6,7 @@ software.xdev.tci bom - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT pom bom @@ -51,62 +51,72 @@ software.xdev.tci base - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev.tci db-jdbc - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev.tci db-jdbc-spring-orm - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev.tci db-jdbc-spring-orm-eclipselink - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev.tci db-jdbc-spring-orm-hibernate - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT + + + software.xdev.tci + image-build + 4.0.0-SNAPSHOT software.xdev.tci jacoco - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev.tci jul-to-slf4j - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev.tci junit-jupiter-api-support - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT + + + software.xdev.tci + mailpit + 4.0.0-SNAPSHOT software.xdev.tci mockserver - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev.tci oidc-server-mock - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev.tci selenium - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev.tci spring-dao-support - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT diff --git a/db-jdbc-spring-orm-eclipselink/pom.xml b/db-jdbc-spring-orm-eclipselink/pom.xml index 3508dd9e..16226b22 100644 --- a/db-jdbc-spring-orm-eclipselink/pom.xml +++ b/db-jdbc-spring-orm-eclipselink/pom.xml @@ -6,7 +6,7 @@ software.xdev.tci db-jdbc-spring-orm-eclipselink - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT jar db-jdbc-spring-orm-eclipselink @@ -53,13 +53,13 @@ software.xdev.tci db-jdbc-spring-orm - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT org.eclipse.persistence eclipselink - 5.0.0 + 5.0.1 @@ -226,7 +226,7 @@ org.sonatype.central central-publishing-maven-plugin - 0.10.0 + 0.11.0 true sonatype-central-portal @@ -248,7 +248,7 @@ com.puppycrawl.tools checkstyle - 13.5.0 + 13.8.0 @@ -286,12 +286,12 @@ net.sourceforge.pmd pmd-core - 7.25.0 + 7.26.0 net.sourceforge.pmd pmd-java - 7.25.0 + 7.26.0 diff --git a/db-jdbc-spring-orm-eclipselink/src/main/java/software/xdev/tci/db/persistence/eclipselink/EclipseLinkEntityManagerControllerFactory.java b/db-jdbc-spring-orm-eclipselink/src/main/java/software/xdev/tci/db/persistence/eclipselink/EclipseLinkEntityManagerControllerFactory.java index 7308aab3..a344b7da 100644 --- a/db-jdbc-spring-orm-eclipselink/src/main/java/software/xdev/tci/db/persistence/eclipselink/EclipseLinkEntityManagerControllerFactory.java +++ b/db-jdbc-spring-orm-eclipselink/src/main/java/software/xdev/tci/db/persistence/eclipselink/EclipseLinkEntityManagerControllerFactory.java @@ -19,9 +19,6 @@ import java.util.Set; import java.util.function.Supplier; -import jakarta.persistence.EntityManagerFactory; -import jakarta.persistence.spi.PersistenceUnitInfo; - import org.eclipse.persistence.config.PersistenceUnitProperties; import org.eclipse.persistence.jpa.PersistenceProvider; import org.springframework.orm.jpa.persistenceunit.SpringPersistenceUnitInfo; @@ -30,7 +27,7 @@ public class EclipseLinkEntityManagerControllerFactory - extends SpringEntityManagerControllerFactory + extends SpringEntityManagerControllerFactory { // Disabled (false) by default as it's not used by the default created SpringPersistenceUnitInfo anyway protected boolean weavingEnabled; @@ -69,7 +66,6 @@ protected SpringPersistenceUnitInfo createSpringPersistenceUnitInfo() final SpringPersistenceUnitInfo spui = super.createSpringPersistenceUnitInfo(); // Required otherwise createContainerEntityManagerFactoryImpl crashes spui.setPersistenceUnitRootUrl(this.getClass().getResource("")); - spui.setPersistenceProviderClassName(PersistenceProvider.class.getName()); return spui; } @@ -85,10 +81,8 @@ protected Map buildProperties() } @Override - protected EntityManagerFactory createEntityManagerFactory( - final PersistenceUnitInfo pui, - final Map properties) + protected PersistenceProvider createDefaultPersistenceProvider() { - return new PersistenceProvider().createContainerEntityManagerFactory(pui, properties); + return new PersistenceProvider(); } } diff --git a/db-jdbc-spring-orm-hibernate/pom.xml b/db-jdbc-spring-orm-hibernate/pom.xml index 5d61a77c..9e11668f 100644 --- a/db-jdbc-spring-orm-hibernate/pom.xml +++ b/db-jdbc-spring-orm-hibernate/pom.xml @@ -6,7 +6,7 @@ software.xdev.tci db-jdbc-spring-orm-hibernate - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT jar db-jdbc-spring-orm-hibernate @@ -53,24 +53,24 @@ software.xdev.tci db-jdbc-spring-orm - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT org.hibernate.orm hibernate-core - 7.4.2.Final + 7.4.5.Final org.hibernate.orm hibernate-scan-jandex - 7.4.2.Final + 7.4.5.Final org.hibernate.orm hibernate-hikaricp - 7.4.2.Final + 7.4.5.Final @@ -259,7 +259,7 @@ com.puppycrawl.tools checkstyle - 13.6.0 + 13.8.0 @@ -297,12 +297,12 @@ net.sourceforge.pmd pmd-core - 7.25.0 + 7.26.0 net.sourceforge.pmd pmd-java - 7.25.0 + 7.26.0 diff --git a/db-jdbc-spring-orm-hibernate/src/main/java/software/xdev/tci/db/persistence/hibernate/HibernateEntityManagerControllerFactory.java b/db-jdbc-spring-orm-hibernate/src/main/java/software/xdev/tci/db/persistence/hibernate/HibernateEntityManagerControllerFactory.java index 49fc9332..b2161daf 100644 --- a/db-jdbc-spring-orm-hibernate/src/main/java/software/xdev/tci/db/persistence/hibernate/HibernateEntityManagerControllerFactory.java +++ b/db-jdbc-spring-orm-hibernate/src/main/java/software/xdev/tci/db/persistence/hibernate/HibernateEntityManagerControllerFactory.java @@ -20,20 +20,16 @@ import java.util.Set; import java.util.function.Supplier; -import jakarta.persistence.EntityManagerFactory; -import jakarta.persistence.spi.PersistenceUnitInfo; - import org.hibernate.cfg.JdbcSettings; import org.hibernate.cfg.PersistenceSettings; import org.hibernate.hikaricp.internal.HikariCPConnectionProvider; import org.hibernate.jpa.HibernatePersistenceProvider; -import org.springframework.orm.jpa.persistenceunit.SpringPersistenceUnitInfo; import software.xdev.tci.db.persistence.SpringEntityManagerControllerFactory; public class HibernateEntityManagerControllerFactory - extends SpringEntityManagerControllerFactory + extends SpringEntityManagerControllerFactory { protected String connectionProviderClassName = HikariCPConnectionProvider.class.getName(); protected boolean disableHibernateFormatter = true; @@ -69,14 +65,6 @@ public HibernateEntityManagerControllerFactory withUseCachingStandardScanner( return this; } - @Override - protected SpringPersistenceUnitInfo createSpringPersistenceUnitInfo() - { - final SpringPersistenceUnitInfo spui = super.createSpringPersistenceUnitInfo(); - spui.setPersistenceProviderClassName(HibernatePersistenceProvider.class.getName()); - return spui; - } - @Override protected Map buildProperties() { @@ -95,10 +83,8 @@ protected Map buildProperties() } @Override - protected EntityManagerFactory createEntityManagerFactory( - final PersistenceUnitInfo pui, - final Map properties) + protected HibernatePersistenceProvider createDefaultPersistenceProvider() { - return new HibernatePersistenceProvider().createContainerEntityManagerFactory(pui, properties); + return new HibernatePersistenceProvider(); } } diff --git a/db-jdbc-spring-orm/pom.xml b/db-jdbc-spring-orm/pom.xml index 30410169..fc5c488f 100644 --- a/db-jdbc-spring-orm/pom.xml +++ b/db-jdbc-spring-orm/pom.xml @@ -6,7 +6,7 @@ software.xdev.tci db-jdbc-spring-orm - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT jar db-jdbc-spring-orm @@ -53,7 +53,7 @@ software.xdev.tci db-jdbc - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT @@ -258,7 +258,7 @@ com.puppycrawl.tools checkstyle - 13.6.0 + 13.8.0 @@ -295,12 +295,12 @@ net.sourceforge.pmd pmd-core - 7.25.0 + 7.26.0 net.sourceforge.pmd pmd-java - 7.25.0 + 7.26.0 diff --git a/db-jdbc-spring-orm/src/main/java/software/xdev/tci/db/persistence/SpringEntityManagerControllerFactory.java b/db-jdbc-spring-orm/src/main/java/software/xdev/tci/db/persistence/SpringEntityManagerControllerFactory.java index 8231b06d..58d49639 100644 --- a/db-jdbc-spring-orm/src/main/java/software/xdev/tci/db/persistence/SpringEntityManagerControllerFactory.java +++ b/db-jdbc-spring-orm/src/main/java/software/xdev/tci/db/persistence/SpringEntityManagerControllerFactory.java @@ -20,21 +20,27 @@ import java.net.URL; import java.util.Collection; import java.util.Collections; +import java.util.Map; import java.util.Set; import java.util.function.Supplier; +import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.PersistenceUnitTransactionType; import jakarta.persistence.spi.ClassTransformer; +import jakarta.persistence.spi.PersistenceProvider; import jakarta.persistence.spi.PersistenceUnitInfo; import org.springframework.orm.jpa.persistenceunit.SpringPersistenceUnitInfo; @SuppressWarnings("java:S119") -public abstract class SpringEntityManagerControllerFactory> +public abstract class SpringEntityManagerControllerFactory< + P extends PersistenceProvider, + SELF extends SpringEntityManagerControllerFactory> extends EntityManagerControllerFactory { protected boolean addJarFileUrls; + protected P persistenceProvider; protected SpringEntityManagerControllerFactory() { @@ -61,6 +67,15 @@ public SELF withAddJarFileUrls(final boolean addJarFileUrls) return this.self(); } + /** + * Allows to manually configure a (default) persistence provider + */ + public SELF withPersistenceProvider(final P persistenceProvider) + { + this.persistenceProvider = persistenceProvider; + return this.self(); + } + @SuppressWarnings("java:S4449") // can't add annotations to source code that we do not control protected SpringPersistenceUnitInfo instantiateSpringPersistenceUnitInfo() { @@ -100,12 +115,6 @@ protected SpringPersistenceUnitInfo createSpringPersistenceUnitInfo() return pui; } - @Override - protected PersistenceUnitInfo createPersistenceUnitInfo() - { - return this.createSpringPersistenceUnitInfo().asStandardPersistenceUnitInfo(); - } - protected Collection jarFileUrlsToAdd() { try @@ -119,4 +128,24 @@ protected Collection jarFileUrlsToAdd() throw new UncheckedIOException(ioe); } } + + @Override + protected PersistenceUnitInfo createPersistenceUnitInfo() + { + return this.createSpringPersistenceUnitInfo().asStandardPersistenceUnitInfo(); + } + + protected abstract P createDefaultPersistenceProvider(); + + @Override + protected EntityManagerFactory createEntityManagerFactory( + final PersistenceUnitInfo pui, + final Map properties) + { + if(this.persistenceProvider == null) + { + this.persistenceProvider = this.createDefaultPersistenceProvider(); + } + return this.persistenceProvider.createContainerEntityManagerFactory(pui, properties); + } } diff --git a/db-jdbc/pom.xml b/db-jdbc/pom.xml index 34598840..fb25db29 100644 --- a/db-jdbc/pom.xml +++ b/db-jdbc/pom.xml @@ -6,7 +6,7 @@ software.xdev.tci db-jdbc - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT jar db-jdbc @@ -53,7 +53,7 @@ software.xdev.tci base - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT @@ -72,7 +72,7 @@ org.junit.jupiter junit-jupiter - 6.1.0 + 6.1.2 test @@ -268,7 +268,7 @@ com.puppycrawl.tools checkstyle - 13.6.0 + 13.8.0 @@ -306,12 +306,12 @@ net.sourceforge.pmd pmd-core - 7.25.0 + 7.26.0 net.sourceforge.pmd pmd-java - 7.25.0 + 7.26.0 diff --git a/db-jdbc/src/main/java/software/xdev/tci/db/containers/WaitableJDBCContainer.java b/db-jdbc/src/main/java/software/xdev/tci/db/containers/WaitableJDBCContainer.java index 2decd1cf..62ca1a55 100644 --- a/db-jdbc/src/main/java/software/xdev/tci/db/containers/WaitableJDBCContainer.java +++ b/db-jdbc/src/main/java/software/xdev/tci/db/containers/WaitableJDBCContainer.java @@ -16,22 +16,22 @@ package software.xdev.tci.db.containers; import java.sql.Connection; +import java.sql.SQLException; import java.time.Duration; import java.util.concurrent.TimeUnit; -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.containers.ContainerLaunchException; import org.testcontainers.containers.JdbcDatabaseContainer; -import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; -import org.testcontainers.containers.wait.strategy.Wait; -import org.testcontainers.containers.wait.strategy.WaitAllStrategy; import org.testcontainers.containers.wait.strategy.WaitStrategy; import org.testcontainers.containers.wait.strategy.WaitStrategyTarget; import software.xdev.tci.envperf.EnvironmentPerformance; +import software.xdev.tci.startup.wait.AbortMonitor; +import software.xdev.tci.startup.wait.FastAbortOnContainerDeathWaitStrategy; +import software.xdev.tci.startup.wait.strategy.AbstractWaitAbortableStrategy; +import software.xdev.tci.startup.wait.strategy.HostPortWaitAbortableStrategy; public interface WaitableJDBCContainer extends WaitStrategyTarget @@ -39,10 +39,11 @@ public interface WaitableJDBCContainer extends WaitStrategyTarget @SuppressWarnings("checkstyle:MagicNumber") default WaitStrategy completeJDBCWaitStrategy() { - return new WaitAllStrategy() - .withStrategy(Wait.defaultWaitStrategy()) + return FastAbortOnContainerDeathWaitStrategy.waitAll(s -> s + .withStartupTimeout(Duration.ofSeconds(40L + EnvironmentPerformance.cpuSlownessFactor() * 20L)) + .withStrategy(new HostPortWaitAbortableStrategy()) .withStrategy(new JDBCWaitStrategy()) - .withStartupTimeout(Duration.ofSeconds(40L + EnvironmentPerformance.cpuSlownessFactor() * 20L)); + ); } WaitStrategy getWaitStrategy(); @@ -59,20 +60,19 @@ default void waitUntilContainerStarted() /** * @apiNote Assumes that the container is already started */ - class JDBCWaitStrategy extends AbstractWaitStrategy + class JDBCWaitStrategy extends AbstractWaitAbortableStrategy { @SuppressWarnings("checkstyle:MagicNumber") public JDBCWaitStrategy() { this.withRateLimiter(RateLimiterBuilder.newBuilder() - .withRate(200, TimeUnit.MILLISECONDS) + .withRate(5, TimeUnit.SECONDS) .withConstantThroughput() .build()); } - @SuppressWarnings("PMD.PreserveStackTrace") @Override - protected void waitUntilReady() + protected void waitUntilReady(final AbortMonitor abortMonitor) { if(!(this.waitStrategyTarget instanceof final JdbcDatabaseContainer container)) { @@ -80,45 +80,33 @@ protected void waitUntilReady() "Container must implement JdbcDatabaseContainer and WaitableJDBCContainer"); } - try - { - this.waitUntilJDBCValid(container); - } - catch(final TimeoutException e) - { - throw new ContainerLaunchException( - "JDBCContainer cannot be accessed by (JDBC URL: " - + container.getJdbcUrl() - + "), please check container logs"); - } - } - - protected void waitUntilJDBCValid(final JdbcDatabaseContainer container) - { - Unreliables.retryUntilTrue( - (int)this.startupTimeout.getSeconds(), - TimeUnit.SECONDS, - // Rate limit creation of connections as this is quite an expensive operation - () -> this.getRateLimiter().getWhenReady(() -> { + // Rate limit creation of connections as this is quite an expensive operation + this.startupRetryUntilSuccessWithRateLimitWhenNotAborted( + abortMonitor, + () -> { try(final Connection connection = container.createConnection("")) { - return this.waitUntilJDBCConnectionValidated(container, connection); + this.waitUntilJDBCConnectionValidated(container, connection); + } + catch(final SQLException sqlEx) + { + throw new IllegalStateException("SQL failed", sqlEx); } - }) + } ); } @SuppressWarnings({"unused", "checkstyle:MagicNumber"}) // Parameter might be used by extension - protected boolean waitUntilJDBCConnectionValidated( + protected void waitUntilJDBCConnectionValidated( final JdbcDatabaseContainer container, final Connection connection) { final RateLimiter validateJDBCRateLimiter = RateLimiterBuilder.newBuilder() - .withRate(50, TimeUnit.MILLISECONDS) + .withRate(20, TimeUnit.SECONDS) .withConstantThroughput() .build(); - return Unreliables.retryUntilSuccess( + Unreliables.retryUntilSuccess( // If this fails after startupTimeout / 2 (min=3s, max=30s) // it might be possible that the connection is somehow corrupted or broken // -> Build a new connection after that (see waitUntilJDBCValid) diff --git a/image-build/README.md b/image-build/README.md new file mode 100644 index 00000000..2430e99d --- /dev/null +++ b/image-build/README.md @@ -0,0 +1,36 @@ +# Image-Build + +Provides common utility and configuration to build images. + +## Config + +
    The configuration is dynamically loaded from (sorted by highest priority) + +* Environment variables + * prefixed with `TCI_IMAGE-BUILD__` + * where `imageName` is the (sanitized) name of the image to build + * prefixed with `TCI_IMAGE-BUILD_` + * all properties are in UPPERCASE and use `_` instead of `.` or `-` +* System properties + * prefixed with `tci.image-build..` + * where `imageName` is the (sanitized) name of the image to build + * prefixed with `tci.image-build.` + +_NOTE: Sanitized image-names only include alphanumeric characters, `_` or `-`. All other characters are replaced by `_`_ + +
    + +
    Full list of configuration options + +| Property | Type | Default | Notes | +| --- | --- | --- | --- | +| `delete-on-exit` | `bool` | `false` | Should the image be deleted on exit? | +| `logger-for-build-prefix` | `string` | `container.build.` | Prefix used for the build logger | +| `cache-from` | `string` | - | Only applies to BuildKit (native) build.
    See [Docker docs](https://docs.docker.com/build/cache/backends/) for details. | +| `cache-to` | `string` | - | Only applies to BuildKit (native) build.
    See [Docker docs](https://docs.docker.com/build/cache/backends/) for details. | +| `save-cache-in-background` | `bool` | if `cache-to` set `true`
    otherwise `false` | Builds the image the first time WITHOUT `cache-to` and the async in the background again WITH `cache-to`. This way saving the cache does not delay test execution. | +| `wait-for-save-cache-in-background` | `bool` | `true` | Wait until all caches are saved before terminating | + +
    + + diff --git a/image-build/pom.xml b/image-build/pom.xml new file mode 100644 index 00000000..0efd17d3 --- /dev/null +++ b/image-build/pom.xml @@ -0,0 +1,311 @@ + + + 4.0.0 + + software.xdev.tci + image-build + 4.0.0-SNAPSHOT + jar + + image-build + TCI - image-build + https://github.com/xdev-software/tci + + + https://github.com/xdev-software/tci + scm:git:https://github.com/xdev-software/tci.git + + + 2025 + + + XDEV Software + https://xdev.software + + + + + XDEV Software + XDEV Software + https://xdev.software + + + + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + 21 + ${javaVersion} + + UTF-8 + UTF-8 + + + + + software.xdev.tci + base + 4.0.0-SNAPSHOT + + + software.xdev + testcontainers-advanced-imagebuilder + 4.1.2 + + + + + + + + org.apache.maven.plugins + maven-site-plugin + 4.0.0-M16 + + + org.apache.maven.plugins + maven-project-info-reports-plugin + 3.9.0 + + + + + + com.mycila + license-maven-plugin + 5.0.0 + + + ${project.organization.url} + + + +
    com/mycila/maven/plugin/license/templates/APACHE-2.txt
    + + src/main/java/** + src/test/java/** + +
    +
    +
    + + + first + + format + + process-sources + + +
    + + + org.apache.maven.plugins + maven-compiler-plugin + 3.15.0 + + ${maven.compiler.release} + + -proc:none + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.12.0 + + + attach-javadocs + package + + jar + + + + + true + none + + + + org.apache.maven.plugins + maven-source-plugin + 3.4.0 + + + attach-sources + package + + jar-no-fork + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.6 + +
    +
    + + + ignore-service-loading + + + + src/main/resources + + META-INF/services/** + + + + + + + publish + + + + org.codehaus.mojo + flatten-maven-plugin + 1.7.3 + + ossrh + + + + flatten + process-resources + + flatten + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + + + sign-artifacts + verify + + sign + + + + + + --pinentry-mode + loopback + + + + + + + + + + publish-sonatype-central-portal + + + + org.sonatype.central + central-publishing-maven-plugin + 0.11.0 + true + + sonatype-central-portal + true + + + + + + + checkstyle + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.6.0 + + + com.puppycrawl.tools + checkstyle + 13.8.0 + + + + ../.config/checkstyle/checkstyle.xml + true + + + + + check + + + + + + + + + pmd + + + + org.apache.maven.plugins + maven-pmd-plugin + 3.28.0 + + true + true + true + + ../.config/pmd/java/ruleset.xml + + + + + net.sourceforge.pmd + pmd-core + 7.26.0 + + + net.sourceforge.pmd + pmd-java + 7.26.0 + + + + + + + + + + org.apache.maven.plugins + maven-jxr-plugin + 3.6.0 + + + + + +
    diff --git a/image-build/src/main/java/software/xdev/tci/imagebuild/BuildImage.java b/image-build/src/main/java/software/xdev/tci/imagebuild/BuildImage.java new file mode 100644 index 00000000..aa2c0017 --- /dev/null +++ b/image-build/src/main/java/software/xdev/tci/imagebuild/BuildImage.java @@ -0,0 +1,52 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.imagebuild; + +import java.time.Duration; +import java.util.function.UnaryOperator; + +import software.xdev.tci.serviceloading.TCIServiceLoaderHolder; +import software.xdev.testcontainers.imagebuilder.AdvancedImageFromDockerFile; +import software.xdev.testcontainers.imagebuilder.buildxnative.NativeAdvancedImageFromDockerfile; + + +public final class BuildImage +{ + public static String nativeImage( + final String dockerImage, + final Duration timeout, + final UnaryOperator configure) + { + return impl().nativeImage(dockerImage, timeout, configure); + } + + public static String image( + final String dockerImage, + final Duration timeout, + final UnaryOperator configure) + { + return impl().image(dockerImage, timeout, configure); + } + + public static BuildImageHandlerProvider impl() + { + return TCIServiceLoaderHolder.instance().service(BuildImageHandlerProvider.class); + } + + private BuildImage() + { + } +} diff --git a/image-build/src/main/java/software/xdev/tci/imagebuild/BuildImageHandlerProvider.java b/image-build/src/main/java/software/xdev/tci/imagebuild/BuildImageHandlerProvider.java new file mode 100644 index 00000000..ed57af9e --- /dev/null +++ b/image-build/src/main/java/software/xdev/tci/imagebuild/BuildImageHandlerProvider.java @@ -0,0 +1,36 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.imagebuild; + +import java.time.Duration; +import java.util.function.UnaryOperator; + +import software.xdev.testcontainers.imagebuilder.AdvancedImageFromDockerFile; +import software.xdev.testcontainers.imagebuilder.buildxnative.NativeAdvancedImageFromDockerfile; + + +public interface BuildImageHandlerProvider +{ + String nativeImage( + String dockerImage, + Duration timeout, + UnaryOperator configure); + + String image( + String dockerImage, + Duration timeout, + UnaryOperator configure); +} diff --git a/image-build/src/main/java/software/xdev/tci/imagebuild/DefaultBuildImageHandlerProvider.java b/image-build/src/main/java/software/xdev/tci/imagebuild/DefaultBuildImageHandlerProvider.java new file mode 100644 index 00000000..a2aa4df0 --- /dev/null +++ b/image-build/src/main/java/software/xdev/tci/imagebuild/DefaultBuildImageHandlerProvider.java @@ -0,0 +1,67 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.imagebuild; + +import java.time.Duration; +import java.util.function.UnaryOperator; + +import software.xdev.tci.imagebuild.config.BuildImageHandlerConfig; +import software.xdev.tci.imagebuild.handler.AdvancedBuildImageHandler; +import software.xdev.tci.imagebuild.handler.NativeAdvancedBuildImageHandler; +import software.xdev.testcontainers.imagebuilder.AdvancedImageFromDockerFile; +import software.xdev.testcontainers.imagebuilder.buildxnative.NativeAdvancedImageFromDockerfile; + + +public class DefaultBuildImageHandlerProvider implements BuildImageHandlerProvider +{ + protected final BuildImageHandlerConfig config; + + public DefaultBuildImageHandlerProvider() + { + this(BuildImageHandlerConfig.instance()); + } + + public DefaultBuildImageHandlerProvider(final BuildImageHandlerConfig config) + { + this.config = config; + } + + @Override + public String nativeImage( + final String dockerImage, + final Duration timeout, + final UnaryOperator configure) + { + return new NativeAdvancedBuildImageHandler().build( + dockerImage, + this.config, + timeout, + configure); + } + + @Override + public String image( + final String dockerImage, + final Duration timeout, + final UnaryOperator configure) + { + return new AdvancedBuildImageHandler().build( + dockerImage, + this.config, + timeout, + configure); + } +} diff --git a/image-build/src/main/java/software/xdev/tci/imagebuild/cache/async/TCICacheSaveRegistry.java b/image-build/src/main/java/software/xdev/tci/imagebuild/cache/async/TCICacheSaveRegistry.java new file mode 100644 index 00000000..aaae1d0f --- /dev/null +++ b/image-build/src/main/java/software/xdev/tci/imagebuild/cache/async/TCICacheSaveRegistry.java @@ -0,0 +1,60 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.imagebuild.cache.async; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; + + +public class TCICacheSaveRegistry +{ + protected static TCICacheSaveRegistry instance; + + public static TCICacheSaveRegistry instance() + { + if(instance == null) + { + createDefaultInstance(); + } + return instance; + } + + protected static synchronized void createDefaultInstance() + { + if(instance == null) + { + instance = new TCICacheSaveRegistry(); + } + } + + protected final List> cfs = Collections.synchronizedList(new ArrayList<>()); + + public void add(final CompletableFuture cf) + { + this.cfs.add(cf); + } + + public List> get() + { + return this.cfs; + } + + protected TCICacheSaveRegistry() + { + } +} diff --git a/image-build/src/main/java/software/xdev/tci/imagebuild/cache/async/TCIWaitForCacheSave.java b/image-build/src/main/java/software/xdev/tci/imagebuild/cache/async/TCIWaitForCacheSave.java new file mode 100644 index 00000000..eb81e7d8 --- /dev/null +++ b/image-build/src/main/java/software/xdev/tci/imagebuild/cache/async/TCIWaitForCacheSave.java @@ -0,0 +1,84 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.imagebuild.cache.async; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.junit.platform.launcher.TestExecutionListener; +import org.junit.platform.launcher.TestPlan; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import software.xdev.tci.imagebuild.config.BuildImageHandlerConfig; + + +public class TCIWaitForCacheSave implements TestExecutionListener +{ + private static final Logger LOG = LoggerFactory.getLogger(TCIWaitForCacheSave.class); + + private BuildImageHandlerConfig config; + + @Override + public void testPlanExecutionStarted(final TestPlan testPlan) + { + this.config = BuildImageHandlerConfig.instance(); + } + + @Override + public void testPlanExecutionFinished(final TestPlan testPlan) + { + if(!this.config.waitForSaveCacheInBackground()) + { + return; + } + + final List> cfs = TCICacheSaveRegistry.instance().get(); + if(cfs.isEmpty()) + { + return; + } + + LOG.info("Waiting for cache saves to finish..."); + final long startMs = System.currentTimeMillis(); + try + { + CompletableFuture.allOf(cfs.toArray(CompletableFuture[]::new)) + .get(10, TimeUnit.MINUTES); + } + catch(final InterruptedException e) + { + LOG.warn("Got interrupted", e); + Thread.currentThread().interrupt(); + } + catch(final ExecutionException e) + { + LOG.warn("A cache save failed", e); + } + catch(final TimeoutException e) + { + LOG.warn("Timed out while waiting for cache save", e); + } + + LOG.info( + "Finished waiting for {}x cache saves, took {}ms", + cfs.size(), + System.currentTimeMillis() - startMs); + } +} diff --git a/image-build/src/main/java/software/xdev/tci/imagebuild/config/AbstractBuildImageHandlerConfig.java b/image-build/src/main/java/software/xdev/tci/imagebuild/config/AbstractBuildImageHandlerConfig.java new file mode 100644 index 00000000..1f7ef873 --- /dev/null +++ b/image-build/src/main/java/software/xdev/tci/imagebuild/config/AbstractBuildImageHandlerConfig.java @@ -0,0 +1,39 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.imagebuild.config; + +import java.util.Optional; + +import software.xdev.tci.config.DefaultConfig; + + +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") +public abstract class AbstractBuildImageHandlerConfig extends DefaultConfig implements BuildImageHandlerConfig +{ + protected static final String DELETE_ON_EXIT = "delete-on-exit"; + protected static final String LOGGER_FOR_BUILD_PREFIX = "logger-for-build-prefix"; + protected static final String CACHE_FROM = "cache-from"; + protected static final String CACHE_TO = "cache-to"; + protected static final String SAVE_CACHE_IN_BACKGROUND = "save-cache-in-background"; + protected static final String WAIT_FOR_SAVE_CACHE_IN_BACKGROUND = "wait-for-" + SAVE_CACHE_IN_BACKGROUND; + + protected Boolean deleteOnExit; + protected String loggerForBuildPrefix; + protected Optional cacheFrom; + protected Optional cacheTo; + protected Boolean saveCacheInBackground; + protected Boolean waitForSaveCacheInBackground; +} diff --git a/image-build/src/main/java/software/xdev/tci/imagebuild/config/BuildImageHandlerConfig.java b/image-build/src/main/java/software/xdev/tci/imagebuild/config/BuildImageHandlerConfig.java new file mode 100644 index 00000000..1a16d4d4 --- /dev/null +++ b/image-build/src/main/java/software/xdev/tci/imagebuild/config/BuildImageHandlerConfig.java @@ -0,0 +1,41 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.imagebuild.config; + +import java.util.Optional; + +import software.xdev.tci.serviceloading.TCIServiceLoaderHolder; + + +public interface BuildImageHandlerConfig +{ + boolean deleteOnExit(); + + String loggerForBuildPrefix(); + + Optional cacheFrom(); + + Optional cacheTo(); + + boolean saveCacheInBackground(); + + boolean waitForSaveCacheInBackground(); + + static BuildImageHandlerConfig instance() + { + return TCIServiceLoaderHolder.instance().service(BuildImageHandlerConfig.class); + } +} diff --git a/image-build/src/main/java/software/xdev/tci/imagebuild/config/DefaultBuildImageHandlerConfig.java b/image-build/src/main/java/software/xdev/tci/imagebuild/config/DefaultBuildImageHandlerConfig.java new file mode 100644 index 00000000..5cdf6bc9 --- /dev/null +++ b/image-build/src/main/java/software/xdev/tci/imagebuild/config/DefaultBuildImageHandlerConfig.java @@ -0,0 +1,92 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.imagebuild.config; + +import java.util.Optional; + + +@SuppressWarnings({"java:S2789", "OptionalAssignedToNull"}) +public class DefaultBuildImageHandlerConfig extends AbstractBuildImageHandlerConfig +{ + @Override + protected String propertyNamePrefix() + { + return "tci.image-build"; + } + + @Override + public boolean deleteOnExit() + { + if(this.deleteOnExit == null) + { + this.deleteOnExit = this.resolveBool(DELETE_ON_EXIT, false); + } + return this.deleteOnExit; + } + + @Override + public String loggerForBuildPrefix() + { + if(this.loggerForBuildPrefix == null) + { + this.loggerForBuildPrefix = this.resolve(LOGGER_FOR_BUILD_PREFIX) + .orElse("container.build."); + } + return this.loggerForBuildPrefix; + } + + @Override + public Optional cacheFrom() + { + if(this.cacheFrom == null) + { + this.cacheFrom = this.resolve(CACHE_FROM); + } + return this.cacheFrom; + } + + @Override + public Optional cacheTo() + { + if(this.cacheTo == null) + { + this.cacheTo = this.resolve(CACHE_TO); + } + return this.cacheTo; + } + + @Override + public boolean saveCacheInBackground() + { + if(this.saveCacheInBackground == null) + { + this.saveCacheInBackground = + this.resolveBool(SAVE_CACHE_IN_BACKGROUND, this.cacheTo().isPresent()); + } + return this.saveCacheInBackground; + } + + @Override + public boolean waitForSaveCacheInBackground() + { + if(this.waitForSaveCacheInBackground == null) + { + this.waitForSaveCacheInBackground = + this.resolveBool(WAIT_FOR_SAVE_CACHE_IN_BACKGROUND, true); + } + return this.waitForSaveCacheInBackground; + } +} diff --git a/image-build/src/main/java/software/xdev/tci/imagebuild/config/OverlayBuildImageHandlerConfig.java b/image-build/src/main/java/software/xdev/tci/imagebuild/config/OverlayBuildImageHandlerConfig.java new file mode 100644 index 00000000..65ffafdc --- /dev/null +++ b/image-build/src/main/java/software/xdev/tci/imagebuild/config/OverlayBuildImageHandlerConfig.java @@ -0,0 +1,92 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.imagebuild.config; + +import java.util.Optional; + + +@SuppressWarnings({"java:S2789", "OptionalAssignedToNull"}) +public class OverlayBuildImageHandlerConfig extends DefaultBuildImageHandlerConfig +{ + private final BuildImageHandlerConfig parentConfig; + private final String prefixName; + + public OverlayBuildImageHandlerConfig(final BuildImageHandlerConfig parentConfig, final String prefixName) + { + this.parentConfig = parentConfig; + this.prefixName = prefixName; + } + + @Override + protected String propertyNamePrefix() + { + return super.propertyNamePrefix() + "." + this.prefixName; + } + + @Override + public boolean deleteOnExit() + { + if(this.deleteOnExit == null) + { + this.deleteOnExit = this.resolveBool(DELETE_ON_EXIT, this.parentConfig.deleteOnExit()); + } + return this.deleteOnExit; + } + + @Override + public String loggerForBuildPrefix() + { + if(this.loggerForBuildPrefix == null) + { + this.loggerForBuildPrefix = this.resolve(LOGGER_FOR_BUILD_PREFIX) + .orElseGet(this.parentConfig::loggerForBuildPrefix); + } + return this.loggerForBuildPrefix; + } + + @Override + public Optional cacheFrom() + { + if(this.cacheFrom == null) + { + this.cacheFrom = this.resolve(CACHE_FROM) + .or(this.parentConfig::cacheFrom); + } + return this.cacheFrom; + } + + @Override + public Optional cacheTo() + { + if(this.cacheTo == null) + { + this.cacheTo = this.resolve(CACHE_TO) + .or(this.parentConfig::cacheTo); + } + return this.cacheTo; + } + + @Override + public boolean waitForSaveCacheInBackground() + { + if(this.waitForSaveCacheInBackground == null) + { + this.waitForSaveCacheInBackground = + this.resolveBool(WAIT_FOR_SAVE_CACHE_IN_BACKGROUND, this.parentConfig.waitForSaveCacheInBackground()); + } + return this.waitForSaveCacheInBackground; + } +} diff --git a/image-build/src/main/java/software/xdev/tci/imagebuild/handler/AbstractBuildImageHandler.java b/image-build/src/main/java/software/xdev/tci/imagebuild/handler/AbstractBuildImageHandler.java new file mode 100644 index 00000000..a2412174 --- /dev/null +++ b/image-build/src/main/java/software/xdev/tci/imagebuild/handler/AbstractBuildImageHandler.java @@ -0,0 +1,89 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.imagebuild.handler; + +import java.time.Duration; +import java.util.function.UnaryOperator; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import software.xdev.tci.imagebuild.config.BuildImageHandlerConfig; +import software.xdev.tci.imagebuild.config.OverlayBuildImageHandlerConfig; +import software.xdev.testcontainers.imagebuilder.AbstractImageFromDockerfile; + + +public abstract class AbstractBuildImageHandler> + implements BuildImageHandler +{ + protected static final Pattern DOCKER_IMAGE_SANITIZATION_PATTERN = Pattern.compile("[^A-Za-z0-9-_]"); + + protected Logger logger; + + protected AbstractBuildImageHandler() + { + this.logger = LoggerFactory.getLogger(this.getClass()); + } + + @Override + public String build( + final String dockerImage, + final BuildImageHandlerConfig parentConfig, + final Duration timeout, + final UnaryOperator configure) + { + final String sanitizedDockerImageName = this.sanitizeDockerImageName(dockerImage); + return this.build( + dockerImage, + sanitizedDockerImageName, + new OverlayBuildImageHandlerConfig(parentConfig, sanitizedDockerImageName), + timeout, + configure); + } + + protected abstract String build( + final String dockerImage, + final String sanitizedDockerImageName, + final BuildImageHandlerConfig parentConfig, + final Duration timeout, + final UnaryOperator configure); + + protected void configureAbstract( + final I builder, + final BuildImageHandlerConfig config, + final String sanitizeDockerImageName) + { + builder.withLoggerForBuild( + LoggerFactory.getLogger(config.loggerForBuildPrefix() + sanitizeDockerImageName)); + } + + protected String buildImage( + final AbstractImageFromDockerfile builder, + final Duration timeout) + { + this.logger.info("Building image {}...", builder.getDockerImageName()); + final String builtImageName = builder.build(timeout); + this.logger.info("Built image {}", builtImageName); + + return builtImageName; + } + + protected String sanitizeDockerImageName(final String dockerImage) + { + return DOCKER_IMAGE_SANITIZATION_PATTERN.matcher(dockerImage).replaceAll("_"); + } +} diff --git a/image-build/src/main/java/software/xdev/tci/imagebuild/handler/AdvancedBuildImageHandler.java b/image-build/src/main/java/software/xdev/tci/imagebuild/handler/AdvancedBuildImageHandler.java new file mode 100644 index 00000000..cd013563 --- /dev/null +++ b/image-build/src/main/java/software/xdev/tci/imagebuild/handler/AdvancedBuildImageHandler.java @@ -0,0 +1,44 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.imagebuild.handler; + +import java.time.Duration; +import java.util.function.UnaryOperator; + +import software.xdev.tci.imagebuild.config.BuildImageHandlerConfig; +import software.xdev.testcontainers.imagebuilder.AdvancedImageFromDockerFile; + + +public class AdvancedBuildImageHandler extends AbstractBuildImageHandler +{ + @Override + protected String build( + final String dockerImage, + final String sanitizedDockerImageName, + final BuildImageHandlerConfig config, + final Duration timeout, + final UnaryOperator configure) + { + this.logger.info("Starting build of image {}", dockerImage); + + final AdvancedImageFromDockerFile builder = + new AdvancedImageFromDockerFile(dockerImage, config.deleteOnExit()); + + this.configureAbstract(builder, config, sanitizedDockerImageName); + + return this.buildImage(configure.apply(builder), timeout); + } +} diff --git a/image-build/src/main/java/software/xdev/tci/imagebuild/handler/BuildImageHandler.java b/image-build/src/main/java/software/xdev/tci/imagebuild/handler/BuildImageHandler.java new file mode 100644 index 00000000..399c9b82 --- /dev/null +++ b/image-build/src/main/java/software/xdev/tci/imagebuild/handler/BuildImageHandler.java @@ -0,0 +1,31 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.imagebuild.handler; + +import java.time.Duration; +import java.util.function.UnaryOperator; + +import software.xdev.tci.imagebuild.config.BuildImageHandlerConfig; + + +public interface BuildImageHandler +{ + String build( + String dockerImage, + BuildImageHandlerConfig parentConfig, + Duration timeout, + UnaryOperator configure); +} diff --git a/image-build/src/main/java/software/xdev/tci/imagebuild/handler/NativeAdvancedBuildImageHandler.java b/image-build/src/main/java/software/xdev/tci/imagebuild/handler/NativeAdvancedBuildImageHandler.java new file mode 100644 index 00000000..112c9b8f --- /dev/null +++ b/image-build/src/main/java/software/xdev/tci/imagebuild/handler/NativeAdvancedBuildImageHandler.java @@ -0,0 +1,122 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.imagebuild.handler; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.UnaryOperator; + +import software.xdev.tci.concurrent.TCIExecutorServiceHolder; +import software.xdev.tci.imagebuild.cache.async.TCICacheSaveRegistry; +import software.xdev.tci.imagebuild.config.BuildImageHandlerConfig; +import software.xdev.testcontainers.imagebuilder.buildxnative.NativeAdvancedImageFromDockerfile; + + +public class NativeAdvancedBuildImageHandler extends AbstractBuildImageHandler +{ + @Override + protected String build( + final String dockerImage, + final String sanitizedDockerImageName, + final BuildImageHandlerConfig config, + final Duration timeout, + final UnaryOperator configure) + { + this.logger.info("Starting build of (native) image {}", dockerImage); + + final NativeAdvancedImageFromDockerfile builder = + new NativeAdvancedImageFromDockerfile(dockerImage, config.deleteOnExit()); + + this.configureAbstract(builder, config, sanitizedDockerImageName); + + this.templateCacheConfigValue(config.cacheFrom(), sanitizedDockerImageName) + .ifPresent(builder::withCacheFrom); + + if(config.saveCacheInBackground()) + { + builder.withCreateTransferFilesCache(true); + } + else + { + this.configureCacheTo(sanitizedDockerImageName, config, builder); + } + + final NativeAdvancedImageFromDockerfile customizedBuilder = configure.apply(builder); + + final String builtImageName = this.buildImage(customizedBuilder, timeout); + + if(config.saveCacheInBackground() && config.cacheTo().isPresent()) + { + this.logger.info("Rebuilding image {} in background to save cache", builtImageName); + TCICacheSaveRegistry.instance().add( + CompletableFuture.runAsync( + () -> { + try + { + this.buildImage( + this.configureCacheTo( + sanitizedDockerImageName, + config, + customizedBuilder.copyForExactRebuild(dockerImage + "-cache") + // Don't load it into docker because it's not needed there + .withLoad(false)), + timeout); + } + catch(final Exception ex) + { + this.logger.warn("Rebuilding {} to save cache failed", builtImageName, ex); + } + finally + { + try + { + builder.cleanCreatedTransferFilesCache(); + } + catch(final Exception ex) + { + this.logger.warn( + "Failed to clean created transfer file cache for {}", + builtImageName, + ex); + } + } + }, + TCIExecutorServiceHolder.instance())); + } + + return builtImageName; + } + + protected NativeAdvancedImageFromDockerfile configureCacheTo( + final String sanitizeDockerImageName, + final BuildImageHandlerConfig config, + final NativeAdvancedImageFromDockerfile builder) + { + this.templateCacheConfigValue(config.cacheTo(), sanitizeDockerImageName) + .ifPresent(builder::withCacheTo); + return builder; + } + + protected Optional templateCacheConfigValue( + final Optional configValue, + final String sanitizeDockerImageName) + { + return configValue + .map(s -> s.replace("$image", sanitizeDockerImageName)) + .map(s -> s.replace("§image", sanitizeDockerImageName)); + } +} diff --git a/image-build/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener b/image-build/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener new file mode 100644 index 00000000..8604e1ab --- /dev/null +++ b/image-build/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener @@ -0,0 +1 @@ +software.xdev.tci.imagebuild.cache.async.TCIWaitForCacheSave diff --git a/image-build/src/main/resources/META-INF/services/software.xdev.tci.imagebuild.BuildImageHandlerProvider b/image-build/src/main/resources/META-INF/services/software.xdev.tci.imagebuild.BuildImageHandlerProvider new file mode 100644 index 00000000..bae2b157 --- /dev/null +++ b/image-build/src/main/resources/META-INF/services/software.xdev.tci.imagebuild.BuildImageHandlerProvider @@ -0,0 +1 @@ +software.xdev.tci.imagebuild.DefaultBuildImageHandlerProvider diff --git a/image-build/src/main/resources/META-INF/services/software.xdev.tci.imagebuild.config.BuildImageHandlerConfig b/image-build/src/main/resources/META-INF/services/software.xdev.tci.imagebuild.config.BuildImageHandlerConfig new file mode 100644 index 00000000..b71d6a3e --- /dev/null +++ b/image-build/src/main/resources/META-INF/services/software.xdev.tci.imagebuild.config.BuildImageHandlerConfig @@ -0,0 +1 @@ +software.xdev.tci.imagebuild.config.DefaultBuildImageHandlerConfig diff --git a/jacoco/README.md b/jacoco/README.md index 61c1b198..c746e030 100644 --- a/jacoco/README.md +++ b/jacoco/README.md @@ -7,6 +7,30 @@ Provides support for [JaCoCo](https://github.com/jacoco/jacoco) Code Coverage in NOTE: Using this is a bit complex as it also requires some changes to the java application in the container.
    Please have a look at the [advanced-demo](../advanced-demo/) and the corresponding [GitHub Actions workflow](../.github/workflows/run-integration-tests.yml) for details. -### Example Report +## Config + +
    The configuration is dynamically loaded from (sorted by highest priority) + +* Environment variables + * prefixed with `TCI_JACOCO_` + * all properties are in UPPERCASE and use `_` instead of `.` or `-` +* System properties + * prefixed with `tci.jacoco.` + +
    + +
    Full list of configuration options + +| Property | Type | Default | Notes | +| --- | --- | --- | --- | +| `enabled` | `bool` | `false` | You should probably set this to `true` | +| `execution-data-files-dir` | `string` | `target/jacoco-execution-data-files` | Location where the jacoco execution reports/execution data files are stored | +| `move-old-execution-data-files-dir` | `bool` | `true` | If `execution-data-files-dir` already exists it will be moved to `execution-data-files-dir-old` | +| `execution-data-files-dir-old` | `string` | `target/jacoco-execution-data-files-old` | Location where previous jacoco execution reports/execution data files are moved to. If the directory already exists it will be deleted. | +| `execution-data-file-suffix` | `string` | `-jacoco.exec` | Suffix for the jacoco reports/execution data files | + +
    + +## Example Report ![](../assets/JaCoCoExample_Overview.avif) ![](../assets/JaCoCoExample_Details.avif) diff --git a/jacoco/pom.xml b/jacoco/pom.xml index 250c1adf..253a09de 100644 --- a/jacoco/pom.xml +++ b/jacoco/pom.xml @@ -6,7 +6,7 @@ software.xdev.tci jacoco - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT jar jacoco @@ -53,7 +53,7 @@ software.xdev.tci base - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT @@ -242,7 +242,7 @@ com.puppycrawl.tools checkstyle - 13.6.0 + 13.8.0 @@ -280,12 +280,12 @@ net.sourceforge.pmd pmd-core - 7.25.0 + 7.26.0 net.sourceforge.pmd pmd-java - 7.25.0 + 7.26.0 diff --git a/jacoco/src/main/java/software/xdev/tci/jacoco/testbase/JaCoCoRecorder.java b/jacoco/src/main/java/software/xdev/tci/jacoco/testbase/JaCoCoRecorder.java index 428271eb..455e851f 100644 --- a/jacoco/src/main/java/software/xdev/tci/jacoco/testbase/JaCoCoRecorder.java +++ b/jacoco/src/main/java/software/xdev/tci/jacoco/testbase/JaCoCoRecorder.java @@ -46,11 +46,19 @@ public static JaCoCoRecorder instance() { if(instance == null) { - instance = new JaCoCoRecorder(); + createDefaultInstance(); } return instance; } + protected static synchronized void createDefaultInstance() + { + if(instance == null) + { + instance = new JaCoCoRecorder(); + } + } + protected JaCoCoConfig config; protected Map cachedDirForExecutionDataFilesVariantPaths = new ConcurrentHashMap<>(); @@ -79,7 +87,6 @@ public CompletableFuture afterTestAsync( return this.afterTestAsync(optTCI, null, fileSystemFriendlyNameSupplier, variantName); } - @SuppressWarnings("resource") public CompletableFuture afterTestAsync( final Optional> optTCI, final String jaCoCoExecutionDataFilePathInContainer, @@ -124,18 +131,7 @@ protected void stopContainerAndCopy( final GenericContainer container, final String containerPath) { - if(container.isRunning()) - { - // Shutdown container so that jacoco agent dumps the execution data file - try - { - DockerClientFactory.lazyClient().stopContainerCmd(container.getContainerId()).exec(); - } - catch(final Exception ex) - { - LOG.warn("Failed to stop container", ex); - } - } + this.stopContainer(container); try { @@ -160,6 +156,23 @@ protected void stopContainerAndCopy( } } + @SuppressWarnings("resource") + protected void stopContainer(final GenericContainer container) + { + if(container.isRunning()) + { + // Shutdown container so that jacoco agent dumps the execution data file + try + { + DockerClientFactory.lazyClient().stopContainerCmd(container.getContainerId()).exec(); + } + catch(final Exception ex) + { + LOG.warn("Failed to stop container", ex); + } + } + } + protected Path resolveDirForExecutionDataFiles(final String variantName) { return this.cachedDirForExecutionDataFilesVariantPaths.computeIfAbsent( diff --git a/jul-to-slf4j/README.md b/jul-to-slf4j/README.md index d6074722..3959daa0 100644 --- a/jul-to-slf4j/README.md +++ b/jul-to-slf4j/README.md @@ -1,3 +1,3 @@ # JUL to SLF4J -Logging Adapter to redirect [JUL](https://docs.oracle.com/en/java/javase/21/docs/api/java.logging/java/util/logging/package-summary.html) to [SLF4J](https://github.com/qos-ch/slf4j) +Logging Adapter to redirect [JUL](https://docs.oracle.com/en/java/javase/25/docs/api/java.logging/java/util/logging/package-summary.html) to [SLF4J](https://github.com/qos-ch/slf4j) diff --git a/jul-to-slf4j/pom.xml b/jul-to-slf4j/pom.xml index 975557c2..f60c13fe 100644 --- a/jul-to-slf4j/pom.xml +++ b/jul-to-slf4j/pom.xml @@ -6,7 +6,7 @@ software.xdev.tci jul-to-slf4j - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT jar jul-to-slf4j @@ -242,7 +242,7 @@ com.puppycrawl.tools checkstyle - 13.6.0 + 13.8.0 @@ -280,12 +280,12 @@ net.sourceforge.pmd pmd-core - 7.25.0 + 7.26.0 net.sourceforge.pmd pmd-java - 7.25.0 + 7.26.0 diff --git a/jul-to-slf4j/src/main/java/software/xdev/tci/logging/JULtoSLF4JRedirector.java b/jul-to-slf4j/src/main/java/software/xdev/tci/logging/JULtoSLF4JRedirector.java index be2d0675..6c41107d 100644 --- a/jul-to-slf4j/src/main/java/software/xdev/tci/logging/JULtoSLF4JRedirector.java +++ b/jul-to-slf4j/src/main/java/software/xdev/tci/logging/JULtoSLF4JRedirector.java @@ -35,6 +35,17 @@ protected void redirectInternal() { return; } + + this.redirectInternalSync(); + } + + protected synchronized void redirectInternalSync() + { + if(this.installed) + { + return; + } + if(SLF4JBridgeHandler.isInstalled()) { this.installed = true; diff --git a/junit-jupiter-api-support/pom.xml b/junit-jupiter-api-support/pom.xml index 1d5a9864..b950b2e4 100644 --- a/junit-jupiter-api-support/pom.xml +++ b/junit-jupiter-api-support/pom.xml @@ -6,7 +6,7 @@ software.xdev.tci junit-jupiter-api-support - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT jar junit-jupiter-api-support @@ -53,7 +53,7 @@ org.junit.jupiter junit-jupiter-api - 6.1.0 + 6.1.2 compile @@ -243,7 +243,7 @@ com.puppycrawl.tools checkstyle - 13.6.0 + 13.8.0 @@ -281,12 +281,12 @@ net.sourceforge.pmd pmd-core - 7.25.0 + 7.26.0 net.sourceforge.pmd pmd-java - 7.25.0 + 7.26.0 diff --git a/mailpit/Dockerfile b/mailpit/Dockerfile new file mode 100644 index 00000000..3c9c49a3 --- /dev/null +++ b/mailpit/Dockerfile @@ -0,0 +1,3 @@ +# This file is just used as a reminder to update the latest minor version in +# MailpitContainer.java from time to time +FROM axllent/mailpit:v1.30 diff --git a/mailpit/README.md b/mailpit/README.md new file mode 100644 index 00000000..9d251cbb --- /dev/null +++ b/mailpit/README.md @@ -0,0 +1,3 @@ +# Mailpit + +TCI for [Mailpit](https://github.com/axllent/mailpit). diff --git a/mailpit/pom.xml b/mailpit/pom.xml new file mode 100644 index 00000000..1edbaf10 --- /dev/null +++ b/mailpit/pom.xml @@ -0,0 +1,354 @@ + + + 4.0.0 + + software.xdev.tci + mailpit + 4.0.0-SNAPSHOT + jar + + mailpit + TCI - mailpit + https://github.com/xdev-software/tci + + + https://github.com/xdev-software/tci + scm:git:https://github.com/xdev-software/tci.git + + + 2025 + + + XDEV Software + https://xdev.software + + + + + XDEV Software + XDEV Software + https://xdev.software + + + + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + 21 + ${javaVersion} + + UTF-8 + UTF-8 + + + true + + 2.26.1 + + + + + software.xdev.tci + base + 4.0.0-SNAPSHOT + + + + software.xdev + mailpit-java-client + 1.0.0 + + + + + org.junit.jupiter + junit-jupiter + 6.1.2 + test + + + + org.apache.logging.log4j + log4j-core + ${log4j.version} + test + + + org.apache.logging.log4j + log4j-slf4j2-impl + ${log4j.version} + test + + + + org.simplejavamail + simple-java-mail + 9.1.0 + test + + + + + + + + org.apache.maven.plugins + maven-site-plugin + 4.0.0-M16 + + + org.apache.maven.plugins + maven-project-info-reports-plugin + 3.9.0 + + + + + + com.mycila + license-maven-plugin + 5.0.0 + + + ${project.organization.url} + + + +
    com/mycila/maven/plugin/license/templates/APACHE-2.txt
    + + src/main/java/** + src/test/java/** + +
    +
    +
    + + + first + + format + + process-sources + + +
    + + + org.apache.maven.plugins + maven-compiler-plugin + 3.15.0 + + ${maven.compiler.release} + + -proc:none + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.12.0 + + + attach-javadocs + package + + jar + + + + + true + none + + + + org.apache.maven.plugins + maven-source-plugin + 3.4.0 + + + attach-sources + package + + jar-no-fork + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.6 + + ${skipTests} + + +
    +
    + + + run-it + + false + + + + ignore-service-loading + + + + src/main/resources + + META-INF/services/** + + + + + + + publish + + + + org.codehaus.mojo + flatten-maven-plugin + 1.7.3 + + ossrh + + + + flatten + process-resources + + flatten + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + + + sign-artifacts + verify + + sign + + + + + + --pinentry-mode + loopback + + + + + + + + + + publish-sonatype-central-portal + + + + org.sonatype.central + central-publishing-maven-plugin + 0.11.0 + true + + sonatype-central-portal + true + + + + + + + checkstyle + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.6.0 + + + com.puppycrawl.tools + checkstyle + 13.8.0 + + + + ../.config/checkstyle/checkstyle.xml + true + + + + + check + + + + + + + + + pmd + + + + org.apache.maven.plugins + maven-pmd-plugin + 3.28.0 + + true + true + true + + ../.config/pmd/java/ruleset.xml + + + + + net.sourceforge.pmd + pmd-core + 7.26.0 + + + net.sourceforge.pmd + pmd-java + 7.26.0 + + + + + + + + + + org.apache.maven.plugins + maven-jxr-plugin + 3.6.0 + + + + + +
    diff --git a/mailpit/src/main/java/software/xdev/tci/mailpit/MailpitTCI.java b/mailpit/src/main/java/software/xdev/tci/mailpit/MailpitTCI.java new file mode 100644 index 00000000..e63c7639 --- /dev/null +++ b/mailpit/src/main/java/software/xdev/tci/mailpit/MailpitTCI.java @@ -0,0 +1,87 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.mailpit; + +import java.time.Duration; + +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.core5.util.Timeout; +import org.slf4j.LoggerFactory; + +import software.xdev.mailpit.client.ApiClient; +import software.xdev.tci.TCI; +import software.xdev.tci.mailpit.containers.MailpitContainer; + + +public class MailpitTCI extends TCI +{ + protected ApiClient apiClient; + + public MailpitTCI(final MailpitContainer container, final String networkAlias) + { + super(container, networkAlias); + } + + public String getExternalHTTPEndpoint() + { + return "http://" + + this.getContainer().getHost() + + ":" + + this.getContainer().getMappedPort(MailpitContainer.WEB_PORT); + } + + public ApiClient apiClient() + { + if(this.apiClient != null) + { + return this.apiClient; + } + + final Duration defaultTimeout = Duration.ofSeconds(30); + + this.apiClient = new ApiClient(); + this.apiClient.setHttpClient(HttpClientBuilder.create() + .setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create() + .setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(Timeout.of(defaultTimeout)) + .setSocketTimeout(Timeout.of(defaultTimeout)) + .build()) + .build()) + .build()); + this.apiClient.setBasePath(this.getExternalHTTPEndpoint()); + + return this.apiClient; + } + + @Override + public void stop() + { + if(this.apiClient != null) + { + try + { + this.apiClient.getHttpClient().close(); + } + catch(final Exception e) + { + LoggerFactory.getLogger(this.getClass()).warn("Failed to close API client", e); + } + } + super.stop(); + } +} diff --git a/mailpit/src/main/java/software/xdev/tci/mailpit/containers/MailpitContainer.java b/mailpit/src/main/java/software/xdev/tci/mailpit/containers/MailpitContainer.java new file mode 100644 index 00000000..785088f2 --- /dev/null +++ b/mailpit/src/main/java/software/xdev/tci/mailpit/containers/MailpitContainer.java @@ -0,0 +1,60 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.mailpit.containers; + +import java.util.Map; +import java.util.stream.Collectors; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + + +public class MailpitContainer extends GenericContainer +{ + public static final DockerImageName DEFAULT_IMAGE = DockerImageName.parse("axllent/mailpit:v1.30"); + + public static final int WEB_PORT = 8025; + public static final int SMTP_PORT = 1025; + + public MailpitContainer() + { + super(DEFAULT_IMAGE); + + // Version check is not needed in tests + this.addEnv("MP_DISABLE_VERSION_CHECK", "true"); + // Resolving client reverse dns is not needed + this.addEnv("MP_SMTP_DISABLE_RDNS", "true"); + + // Enforce CSP (only important when debugging) + this.addEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS", "true"); + + // Use normal SMTP + this.addEnv("MP_SMTP_AUTH_ALLOW_INSECURE", "true"); + + this.addExposedPort(WEB_PORT); + } + + public MailpitContainer withSmtpAuth(final Map usernamePasswords) + { + this.addEnv( + "MP_SMTP_AUTH", + usernamePasswords.entrySet() + .stream() + .map(e -> e.getKey() + ":" + e.getValue()) + .collect(Collectors.joining(" "))); + return this; + } +} diff --git a/mailpit/src/main/java/software/xdev/tci/mailpit/factory/MailpitTCIFactory.java b/mailpit/src/main/java/software/xdev/tci/mailpit/factory/MailpitTCIFactory.java new file mode 100644 index 00000000..a5d172fc --- /dev/null +++ b/mailpit/src/main/java/software/xdev/tci/mailpit/factory/MailpitTCIFactory.java @@ -0,0 +1,56 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.mailpit.factory; + +import java.util.Map; +import java.util.function.Supplier; + +import software.xdev.tci.factory.prestart.PreStartableTCIFactory; +import software.xdev.tci.mailpit.MailpitTCI; +import software.xdev.tci.mailpit.containers.MailpitContainer; +import software.xdev.tci.misc.ContainerMemory; + + +public class MailpitTCIFactory extends PreStartableTCIFactory +{ + public static final String DEFAULT_USER = "no-reply@test.localhost"; + public static final String DEFAULT_PW = "test"; + public static final int SMTP_PORT = MailpitContainer.SMTP_PORT; + + public MailpitTCIFactory() + { + this(MailpitTCIFactory::createDefaultContainer); + } + + public MailpitTCIFactory(final Supplier mailpitContainerSupplier) + { + super( + MailpitTCI::new, + mailpitContainerSupplier, + "mailpit", + "container.mailpit", + "Mailpit" + ); + } + + @SuppressWarnings("resource") + public static MailpitContainer createDefaultContainer() + { + return new MailpitContainer() + .withSmtpAuth(Map.of(DEFAULT_USER, DEFAULT_PW)) + .withCreateContainerCmdModifier(cmd -> cmd.getHostConfig().withMemory(ContainerMemory.M512M)); + } +} diff --git a/mailpit/src/test/java/software/xdev/mailpit/SimpleMailpitTest.java b/mailpit/src/test/java/software/xdev/mailpit/SimpleMailpitTest.java new file mode 100644 index 00000000..ff666022 --- /dev/null +++ b/mailpit/src/test/java/software/xdev/mailpit/SimpleMailpitTest.java @@ -0,0 +1,145 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.mailpit; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.Arrays; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.AfterAll; +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.simplejavamail.api.mailer.Mailer; +import org.simplejavamail.api.mailer.config.TransportStrategy; +import org.simplejavamail.email.EmailBuilder; +import org.simplejavamail.mailer.MailerBuilder; +import org.simplejavamail.recipient.RecipientBuilder; + +import software.xdev.mailpit.api.MessageApi; +import software.xdev.mailpit.client.ApiClient; +import software.xdev.mailpit.model.Message; +import software.xdev.tci.concurrent.TCIExecutorServiceHolder; +import software.xdev.tci.factory.registry.TCIFactoryRegistry; +import software.xdev.tci.mailpit.MailpitTCI; +import software.xdev.tci.mailpit.containers.MailpitContainer; +import software.xdev.tci.mailpit.factory.MailpitTCIFactory; +import software.xdev.tci.network.LazyNetwork; +import software.xdev.tci.network.LazyNetworkPool; + + +class SimpleMailpitTest +{ + protected static final MailpitTCIFactory FACTORY = new MailpitTCIFactory(() -> { + final MailpitContainer container = MailpitTCIFactory.createDefaultContainer(); + // For testing we send mails form the host so SMTP must be exposed + container.addExposedPort(MailpitTCIFactory.SMTP_PORT); + return container; + }); + protected static final LazyNetworkPool LAZY_NETWORK_POOL = new LazyNetworkPool(); + + protected LazyNetwork network; + protected MailpitTCI mailpitInfra; + + @BeforeAll + static void beforeAll() + { + LAZY_NETWORK_POOL.managePoolAsync(); + + TCIFactoryRegistry.instance().warmUp(); + + // Preload classes + CompletableFuture.runAsync(ApiClient::new, TCIExecutorServiceHolder.instance()); + } + + @BeforeEach + void beforeEach() + { + this.network = LAZY_NETWORK_POOL.getNew(); + this.mailpitInfra = FACTORY.getNew(this.network); + } + + @Test + void check() + { + final String toAddress = "m.mustermann@test.localhost"; + final String subject = "Test"; + final String plainText = "This is a test"; + + try(final Mailer mailer = MailerBuilder.withSMTPServer( + this.mailpitInfra.getContainer().getHost(), + this.mailpitInfra.getContainer().getMappedPort(MailpitTCIFactory.SMTP_PORT), + MailpitTCIFactory.DEFAULT_USER, + MailpitTCIFactory.DEFAULT_PW) + .withTransportStrategy(TransportStrategy.SMTP) + .withDebugLogging(true) + .buildMailer()) + { + mailer.sendMail(EmailBuilder.startingBlank() + .from(MailpitTCIFactory.DEFAULT_USER) + .withRecipients(new RecipientBuilder() + .withType(jakarta.mail.Message.RecipientType.TO) + .withAddress(toAddress) + .withName("Max Mustermann") + .build()) + .withSubject(subject) + .withPlainText(plainText) + .buildEmail()); + } + catch(final Exception ex) + { + throw new IllegalStateException("Mailer failed", ex); + } + + final Message message = new MessageApi(this.mailpitInfra.apiClient()).getMessageParams("latest"); + assertNotNull(message); + assertAll( + () -> assertEquals(toAddress, message.getTo().getFirst().getAddress()), + () -> assertEquals(subject, message.getSubject()), + () -> assertEquals( + plainText, + Arrays.stream(message.getText().split("\n")) + .map(String::trim) + .findFirst() + .orElse(null)) + ); + } + + @AfterEach + void afterEach() + { + if(this.mailpitInfra != null) + { + this.mailpitInfra.stop(); + this.mailpitInfra = null; + } + if(this.network != null) + { + this.network.close(); + this.network = null; + } + } + + @AfterAll + static void afterAll() + { + FACTORY.close(); + } +} diff --git a/mailpit/src/test/resources/log4j2-test.xml b/mailpit/src/test/resources/log4j2-test.xml new file mode 100644 index 00000000..3b76bc01 --- /dev/null +++ b/mailpit/src/test/resources/log4j2-test.xml @@ -0,0 +1,28 @@ + + + + + + %d{HH:mm:ss} %-5p [%t] [%-25.25c] %m %n + + + + + + + + + + + + + + + + + + + + + + diff --git a/mockserver/pom.xml b/mockserver/pom.xml index 7a908c8f..c63e30e7 100644 --- a/mockserver/pom.xml +++ b/mockserver/pom.xml @@ -6,7 +6,7 @@ software.xdev.tci mockserver - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT jar mockserver @@ -54,7 +54,7 @@ software.xdev.mockserver bom - 2.51.0 + 2.51.1 pom import @@ -65,7 +65,7 @@ software.xdev.tci base - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT @@ -264,7 +264,7 @@ com.puppycrawl.tools checkstyle - 13.6.0 + 13.8.0 @@ -302,12 +302,12 @@ net.sourceforge.pmd pmd-core - 7.25.0 + 7.26.0 net.sourceforge.pmd pmd-java - 7.25.0 + 7.26.0 diff --git a/mockserver/src/main/java/software/xdev/tci/mockserver/containers/TCIMockserverContainer.java b/mockserver/src/main/java/software/xdev/tci/mockserver/containers/TCIMockserverContainer.java new file mode 100644 index 00000000..44f0eaef --- /dev/null +++ b/mockserver/src/main/java/software/xdev/tci/mockserver/containers/TCIMockserverContainer.java @@ -0,0 +1,82 @@ +/* + * Copyright © 2025 XDEV Software (https://xdev.software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.xdev.tci.mockserver.containers; + +import org.testcontainers.images.RemoteDockerImage; +import org.testcontainers.utility.DockerImageName; + +import com.github.dockerjava.api.command.InspectContainerResponse; + +import software.xdev.tci.misc.ContainerMemory; +import software.xdev.tci.startup.error.java.fatal.HsErrPidStartUpCrashReporter; +import software.xdev.tci.startup.wait.FastAbortOnContainerDeathWaitStrategy; +import software.xdev.tci.startup.wait.strategy.LogMessageWaitAbortableStrategy; +import software.xdev.testcontainers.mockserver.containers.MockServerContainer; + + +@SuppressWarnings("java:S2160") +public class TCIMockserverContainer extends MockServerContainer +{ + private final HsErrPidStartUpCrashReporter hsErrPidStartUpCrashReporter; + + public TCIMockserverContainer(final RemoteDockerImage image) + { + super(image); + this.hsErrPidStartUpCrashReporter = new HsErrPidStartUpCrashReporter(this); + } + + public TCIMockserverContainer(final DockerImageName dockerImageName) + { + super(dockerImageName); + this.hsErrPidStartUpCrashReporter = new HsErrPidStartUpCrashReporter(this); + } + + public TCIMockserverContainer(final String tag) + { + super(tag); + this.hsErrPidStartUpCrashReporter = new HsErrPidStartUpCrashReporter(this); + } + + public TCIMockserverContainer() + { + this.hsErrPidStartUpCrashReporter = new HsErrPidStartUpCrashReporter(this); + } + + @Override + protected void containerIsStarted(final InspectContainerResponse containerInfo, final boolean reused) + { + this.hsErrPidStartUpCrashReporter.containerIsStarted(); + super.containerIsStarted(containerInfo, reused); + } + + @Override + protected void containerIsStopping(final InspectContainerResponse containerInfo) + { + this.hsErrPidStartUpCrashReporter.containerIsStopping(this.logger()); + super.containerIsStopping(containerInfo); + } + + public static TCIMockserverContainer createDefaultForFactory() + { + final TCIMockserverContainer container = new TCIMockserverContainer(); + container + .withCreateContainerCmdModifier(cmd -> cmd.getHostConfig().withMemory(ContainerMemory.M512M)) + .waitingFor(new FastAbortOnContainerDeathWaitStrategy(new LogMessageWaitAbortableStrategy() + .withRegEx(MockServerContainer.LOG_MSG_WAIT_STRATEGY_REGEX) + )); + return container; + } +} diff --git a/mockserver/src/main/java/software/xdev/tci/mockserver/factory/MockServerTCIFactory.java b/mockserver/src/main/java/software/xdev/tci/mockserver/factory/MockServerTCIFactory.java index d3d8b25e..5cb0bf03 100644 --- a/mockserver/src/main/java/software/xdev/tci/mockserver/factory/MockServerTCIFactory.java +++ b/mockserver/src/main/java/software/xdev/tci/mockserver/factory/MockServerTCIFactory.java @@ -22,8 +22,8 @@ import org.testcontainers.containers.Network; import software.xdev.tci.factory.ondemand.OnDemandTCIFactory; -import software.xdev.tci.misc.ContainerMemory; import software.xdev.tci.mockserver.MockServerTCI; +import software.xdev.tci.mockserver.containers.TCIMockserverContainer; import software.xdev.testcontainers.mockserver.containers.MockServerContainer; @@ -39,7 +39,6 @@ protected MockServerTCIFactory( super(infraBuilder, containerBuilder, containerBaseName, containerLoggerName); } - @SuppressWarnings("resource") protected MockServerTCIFactory( final BiFunction infraBuilder, final String additionalContainerBaseName, @@ -47,8 +46,7 @@ protected MockServerTCIFactory( { super( infraBuilder, - () -> new MockServerContainer() - .withCreateContainerCmdModifier(cmd -> cmd.getHostConfig().withMemory(ContainerMemory.M512M)), + TCIMockserverContainer::createDefaultForFactory, "mockserver-" + additionalContainerBaseName, "container.mockserver." + additionalLoggerName); } diff --git a/mockserver/src/main/java/software/xdev/tci/mockserver/factory/PreStartableMockServerTCIFactory.java b/mockserver/src/main/java/software/xdev/tci/mockserver/factory/PreStartableMockServerTCIFactory.java index 14d0346b..465ff0ee 100644 --- a/mockserver/src/main/java/software/xdev/tci/mockserver/factory/PreStartableMockServerTCIFactory.java +++ b/mockserver/src/main/java/software/xdev/tci/mockserver/factory/PreStartableMockServerTCIFactory.java @@ -18,15 +18,14 @@ import java.util.function.BiFunction; import software.xdev.tci.factory.prestart.PreStartableTCIFactory; -import software.xdev.tci.misc.ContainerMemory; import software.xdev.tci.mockserver.MockServerTCI; +import software.xdev.tci.mockserver.containers.TCIMockserverContainer; import software.xdev.testcontainers.mockserver.containers.MockServerContainer; public abstract class PreStartableMockServerTCIFactory extends PreStartableTCIFactory { - @SuppressWarnings("resource") protected PreStartableMockServerTCIFactory( final BiFunction infraBuilder, final String additionalContainerBaseName, @@ -35,8 +34,7 @@ protected PreStartableMockServerTCIFactory( { super( infraBuilder, - () -> new MockServerContainer() - .withCreateContainerCmdModifier(cmd -> cmd.getHostConfig().withMemory(ContainerMemory.M512M)), + TCIMockserverContainer::createDefaultForFactory, "mockserver-" + additionalContainerBaseName, "container.mockserver." + additionalLoggerName, prestartName); diff --git a/oidc-server-mock/pom.xml b/oidc-server-mock/pom.xml index 25ba063d..fad26177 100644 --- a/oidc-server-mock/pom.xml +++ b/oidc-server-mock/pom.xml @@ -6,7 +6,7 @@ software.xdev.tci oidc-server-mock - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT jar oidc-server-mock @@ -53,13 +53,13 @@ software.xdev.tci base - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT org.apache.httpcomponents.client5 httpclient5 - 5.6.1 + 5.6.2 @@ -248,7 +248,7 @@ com.puppycrawl.tools checkstyle - 13.6.0 + 13.8.0 @@ -286,12 +286,12 @@ net.sourceforge.pmd pmd-core - 7.25.0 + 7.26.0 net.sourceforge.pmd pmd-java - 7.25.0 + 7.26.0 diff --git a/oidc-server-mock/src/main/java/software/xdev/tci/oidc/factory/BaseOIDCTCIFactory.java b/oidc-server-mock/src/main/java/software/xdev/tci/oidc/factory/BaseOIDCTCIFactory.java index 62a55a75..37a1e49a 100644 --- a/oidc-server-mock/src/main/java/software/xdev/tci/oidc/factory/BaseOIDCTCIFactory.java +++ b/oidc-server-mock/src/main/java/software/xdev/tci/oidc/factory/BaseOIDCTCIFactory.java @@ -21,9 +21,6 @@ import java.util.function.Supplier; import org.apache.hc.core5.http.HttpStatus; -import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy; -import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; -import org.testcontainers.containers.wait.strategy.WaitAllStrategy; import software.xdev.tci.envperf.EnvironmentPerformance; import software.xdev.tci.factory.prestart.PreStartableTCIFactory; @@ -32,6 +29,9 @@ import software.xdev.tci.oidc.BaseOIDCTCI; import software.xdev.tci.oidc.containers.BaseOIDCServerContainer; import software.xdev.tci.oidc.containers.OIDCServerContainer; +import software.xdev.tci.startup.wait.FastAbortOnContainerDeathWaitStrategy; +import software.xdev.tci.startup.wait.strategy.HostPortWaitAbortableStrategy; +import software.xdev.tci.startup.wait.strategy.HttpWaitAbortableStrategy; @SuppressWarnings("java:S119") @@ -92,22 +92,21 @@ public static OIDCServerContainer createDefaultContainer() return createDefaultContainer(null); } - @SuppressWarnings({"resource", "checkstyle:MagicNumber"}) + @SuppressWarnings({"checkstyle:MagicNumber"}) public static OIDCServerContainer createDefaultContainer(final Consumer customizer) { final OIDCServerContainer oidcServerContainer = new OIDCServerContainer() .withCreateContainerCmdModifier(cmd -> cmd.getHostConfig().withMemory(ContainerMemory.M512M)) - .waitingFor( - new WaitAllStrategy() - .withStartupTimeout(Duration.ofSeconds(40L + 20L * EnvironmentPerformance.cpuSlownessFactor())) - .withStrategy(new HostPortWaitStrategy()) - .withStrategy( - new HttpWaitStrategy() - .forPort(BaseOIDCServerContainer.PORT) - .forPath("/") - .forStatusCode(HttpStatus.SC_OK) - .withReadTimeout(Duration.ofSeconds(10)) - ) + .waitingFor(FastAbortOnContainerDeathWaitStrategy.waitAll(s -> s + .withStartupTimeout(Duration.ofSeconds(40L + 20L * EnvironmentPerformance.cpuSlownessFactor())) + .withStrategy(new HostPortWaitAbortableStrategy()) + .withStrategy( + new HttpWaitAbortableStrategy() + .forPort(BaseOIDCServerContainer.PORT) + .forPath("/") + .forStatusCode(HttpStatus.SC_OK) + .withReadTimeout(Duration.ofSeconds(10)) + )) ); if(customizer != null) diff --git a/pom.xml b/pom.xml index 16a6ad85..d4bf205d 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ software.xdev.tci root - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT pom @@ -21,9 +21,11 @@ db-jdbc-spring-orm db-jdbc-spring-orm-eclipselink db-jdbc-spring-orm-hibernate + image-build jacoco jul-to-slf4j junit-jupiter-api-support + mailpit mockserver oidc-server-mock selenium @@ -77,7 +79,7 @@ com.puppycrawl.tools checkstyle - 13.6.0 + 13.8.0 @@ -115,12 +117,12 @@ net.sourceforge.pmd pmd-core - 7.25.0 + 7.26.0 net.sourceforge.pmd pmd-java - 7.25.0 + 7.26.0 diff --git a/selenium/README.md b/selenium/README.md index 75811157..274838d8 100644 --- a/selenium/README.md +++ b/selenium/README.md @@ -9,3 +9,28 @@ TCI for [Selenium](https://github.com/SeleniumHQ/selenium). * NoVNC support * Full scale support for recording videos * Browser Logs can be enabled if required + +## Config + +
    The configuration is dynamically loaded from (sorted by highest priority) + +* Environment variables + * prefixed with `TCI_SELENIUM_` + * all properties are in UPPERCASE and use `_` instead of `.` or `-` +* System properties + * prefixed with `tci.selenium.` + +
    + +
    Full list of configuration options + +| Property | Type | Default | Notes | +| --- | --- | --- | --- | +| `record-mode` | `Enum` | `RECORD_FAILING` | Recording mode.
    Available:
    • SKIP - Do not record any videos
    • RECORD_ALL - Record all tests
    • RECORD_FAILING - Record failing tests only
    | +| `dir-for-records` | `String` | `target/records` | Directory for storing the recorded videos | +| `vnc-enabled` | `bool` | `false` | Enable [VNC](https://en.wikipedia.org/wiki/VNC) and [NoVNC](https://github.com/novnc/novnc). This is usually only needed during debugging. | +| `bidi-enabled` | `bool` | `true` | Use [Selenium BiDirectional functionality](https://www.selenium.dev/documentation/webdriver/bidi/) instead of legacy Chrome DevTools Protocol (CDP).
    Disabling this will make certain operations unavailable e.g. listening for browser logs. | +| `deactivate-cdp-if-possible` | `bool` | `true` | Disable Chrome DevTools Protocol (CDP) if possible.
    CDP requires additional maven dependencies (e.g. `selenium-devtools-v137)` that are not present by default and will result in a warning. | +| `min-browser-console-log-level` | `Enum` | `ERROR` | Prints out browser console logs. Configures the MINIMUM log level.
    Available options:
    • OFF
    • ERROR
    • WARN
    • INFO
    • DEBUG
    • ALL
    + +
    diff --git a/selenium/pom.xml b/selenium/pom.xml index 9ae5d3ca..1119fe4a 100644 --- a/selenium/pom.xml +++ b/selenium/pom.xml @@ -6,7 +6,7 @@ software.xdev.tci selenium - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT jar selenium @@ -54,7 +54,7 @@ org.seleniumhq.selenium selenium-dependencies-bom - 4.45.0 + 4.46.0 pom import @@ -65,24 +65,24 @@ software.xdev.tci base - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev.tci jul-to-slf4j - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev.tci junit-jupiter-api-support - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT software.xdev testcontainers-selenium - 2.0.0 + 2.0.2 @@ -296,7 +296,7 @@ com.puppycrawl.tools checkstyle - 13.6.0 + 13.8.0 @@ -334,12 +334,12 @@ net.sourceforge.pmd pmd-core - 7.25.0 + 7.26.0 net.sourceforge.pmd pmd-java - 7.25.0 + 7.26.0 diff --git a/selenium/src/main/java/software/xdev/tci/selenium/BrowserTCI.java b/selenium/src/main/java/software/xdev/tci/selenium/BrowserTCI.java index c88cdc11..7009160d 100644 --- a/selenium/src/main/java/software/xdev/tci/selenium/BrowserTCI.java +++ b/selenium/src/main/java/software/xdev/tci/selenium/BrowserTCI.java @@ -62,7 +62,7 @@ public class BrowserTCI extends TCI // https://www.selenium.dev/documentation/webdriver/bidi protected boolean bidiEnabled = true; - // Disables the (not standardized) Chrome Dev Tools (CDP) protocol (when bidi is enabled). + // Disables the (not standardized) Chrome DevTools protocol (CDP) when bidi is enabled. // CDP requires additional maven dependencies (e.g. selenium-devtools-v137) that are // NOT present and result in a warning. protected boolean deactivateCDPIfPossible = true; diff --git a/selenium/src/main/java/software/xdev/tci/selenium/factory/BrowserTCIFactory.java b/selenium/src/main/java/software/xdev/tci/selenium/factory/BrowserTCIFactory.java index 055484ae..f153a52d 100644 --- a/selenium/src/main/java/software/xdev/tci/selenium/factory/BrowserTCIFactory.java +++ b/selenium/src/main/java/software/xdev/tci/selenium/factory/BrowserTCIFactory.java @@ -31,9 +31,6 @@ import org.rnorth.ducttape.unreliables.Unreliables; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy; -import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; -import org.testcontainers.containers.wait.strategy.WaitAllStrategy; import org.testcontainers.images.RemoteDockerImage; import software.xdev.tci.concurrent.TCIExecutorServiceHolder; @@ -45,6 +42,9 @@ import software.xdev.tci.selenium.containers.SeleniumBrowserWebDriverContainer; import software.xdev.tci.selenium.factory.config.BrowserTCIFactoryConfig; import software.xdev.tci.serviceloading.TCIServiceLoaderHolder; +import software.xdev.tci.startup.wait.FastAbortOnContainerDeathWaitStrategy; +import software.xdev.tci.startup.wait.strategy.HostPortWaitAbortableStrategy; +import software.xdev.tci.startup.wait.strategy.LogMessageWaitAbortableStrategy; import software.xdev.testcontainers.selenium.containers.browser.BrowserWebDriverContainer; import software.xdev.testcontainers.selenium.containers.recorder.SeleniumRecordingContainer; @@ -88,8 +88,8 @@ public static BrowserTCI createDefaultBrowserTCI( .withWebDriverRetryCount(Math.max(Math.min(cpuSlownessFactor(), 5), 1)) .withWebDriverRetrySec(25 + cpuSlownessFactor() * 5) .withBrowserConsoleLog( - logBrowserConsoleConsumer(config.browserConsoleLogLevel()), - config.browserConsoleLogLevel().logLevels()); + logBrowserConsoleConsumer(config.minBrowserConsoleLogLevel()), + config.minBrowserConsoleLogLevel().logLevels()); } @SuppressWarnings({"resource", "checkstyle:MagicNumber"}) @@ -119,12 +119,12 @@ public static SeleniumBrowserWebDriverContainer createDefaultContainer( // https://github.com/SeleniumHQ/docker-selenium/issues/2355 .withEnv("SE_ENABLE_TRACING", "false") // Some (AWS) CPUs are completely overloaded with the default 15s timeout -> increase it - .waitingFor(new WaitAllStrategy() - .withStrategy(new LogMessageWaitStrategy() - .withRegEx(".*(Started Selenium Standalone).*\n") - .withStartupTimeout(Duration.ofSeconds(30 + 20L * cpuSlownessFactor()))) - .withStrategy(new HostPortWaitStrategy()) - .withStartupTimeout(Duration.ofSeconds(30 + 20L * cpuSlownessFactor()))); + .waitingFor(FastAbortOnContainerDeathWaitStrategy.waitAll(s -> s + .withStartupTimeout(Duration.ofSeconds(30 + 20L * cpuSlownessFactor())) + .withStrategy(new LogMessageWaitAbortableStrategy() + .withRegEx(BrowserWebDriverContainer.LOG_MSG_WAIT_STRATEGY_REGEX)) + .withStrategy(new HostPortWaitAbortableStrategy()) + )); } public static SeleniumRecordingContainer createDefaultRecordingContainer( @@ -133,7 +133,11 @@ public static SeleniumRecordingContainer createDefaultRecordingContainer( { return new SeleniumRecordingContainer(browserContainer) .withLogConsumer(getLogConsumer("container.browserrecorder." + capabilities.getBrowserName())) - .withCreateContainerCmdModifier(cmd -> cmd.getHostConfig().withMemory(ContainerMemory.M512M)); + .withCreateContainerCmdModifier(cmd -> cmd.getHostConfig().withMemory(ContainerMemory.M512M)) + .waitingFor(new FastAbortOnContainerDeathWaitStrategy( + new LogMessageWaitAbortableStrategy() + .withRegEx(SeleniumRecordingContainer.LOG_MSG_WAIT_STRATEGY_REGEX) + )); } public BrowserTCIFactory( diff --git a/selenium/src/main/java/software/xdev/tci/selenium/factory/config/BrowserTCIFactoryConfig.java b/selenium/src/main/java/software/xdev/tci/selenium/factory/config/BrowserTCIFactoryConfig.java index f83f64f0..883b7d77 100644 --- a/selenium/src/main/java/software/xdev/tci/selenium/factory/config/BrowserTCIFactoryConfig.java +++ b/selenium/src/main/java/software/xdev/tci/selenium/factory/config/BrowserTCIFactoryConfig.java @@ -33,5 +33,5 @@ public interface BrowserTCIFactoryConfig boolean deactivateCdpIfPossible(); - BrowserTCIFactory.BrowserConsoleLogLevel browserConsoleLogLevel(); + BrowserTCIFactory.BrowserConsoleLogLevel minBrowserConsoleLogLevel(); } diff --git a/selenium/src/main/java/software/xdev/tci/selenium/factory/config/DefaultBrowserTCIFactoryConfig.java b/selenium/src/main/java/software/xdev/tci/selenium/factory/config/DefaultBrowserTCIFactoryConfig.java index fa1ac71e..316952ac 100644 --- a/selenium/src/main/java/software/xdev/tci/selenium/factory/config/DefaultBrowserTCIFactoryConfig.java +++ b/selenium/src/main/java/software/xdev/tci/selenium/factory/config/DefaultBrowserTCIFactoryConfig.java @@ -30,12 +30,43 @@ public class DefaultBrowserTCIFactoryConfig extends DefaultConfig implements Bro { private static final Logger LOG = LoggerFactory.getLogger(DefaultBrowserTCIFactoryConfig.class); - public static final String RECORD_MODE = "recordMode"; - public static final String RECORD_DIR = "recordDir"; - public static final String VNC_ENABLED = "vncEnabled"; - public static final String BIDI_ENABLED = "bidiEnabled"; - public static final String DEACTIVATE_CDP_IF_POSSIBLE = "deactivateCdpIfPossible"; - public static final String BROWSER_CONSOLE_LOG_LEVEL = "browserConsoleLogLevel"; + /** + * @deprecated Use non legacy option instead + */ + @Deprecated(since = "4.0.0") + public static final String LEGACY_RECORD_MODE = "recordMode"; + /** + * @deprecated Use non legacy option instead + */ + @Deprecated(since = "4.0.0") + public static final String LEGACY_RECORD_DIR = "recordDir"; + /** + * @deprecated Use non legacy option instead + */ + @Deprecated(since = "4.0.0") + public static final String LEGACY_VNC_ENABLED = "vncEnabled"; + /** + * @deprecated Use non legacy option instead + */ + @Deprecated(since = "4.0.0") + public static final String LEGACY_BIDI_ENABLED = "bidiEnabled"; + /** + * @deprecated Use non legacy option instead + */ + @Deprecated(since = "4.0.0") + public static final String LEGACY_DEACTIVATE_CDP_IF_POSSIBLE = "deactivateCdpIfPossible"; + /** + * @deprecated Use non legacy option instead + */ + @Deprecated(since = "4.0.0") + public static final String LEGACY_BROWSER_CONSOLE_LOG_LEVEL = "browserConsoleLogLevel"; + + public static final String RECORD_MODE = "record-mode"; + public static final String RECORD_DIR = "record-dir"; + public static final String VNC_ENABLED = "vnc-enabled"; + public static final String BIDI_ENABLED = "bidi-enabled"; + public static final String DEACTIVATE_CDP_IF_POSSIBLE = "deactivate-cdp-if-possible"; + public static final String MIN_BROWSER_CONSOLE_LOG_LEVEL = "min-browser-console-log-level"; public static final String DEFAULT_RECORD_DIR = "target/records"; @@ -57,7 +88,10 @@ public BrowserWebDriverContainer.RecordingMode recordingMode() { if(this.systemRecordingMode == null) { - final String resolvedRecordMode = this.resolve(RECORD_MODE).orElse(null); + final String resolvedRecordMode = this.resolve(RECORD_MODE) + .or(() -> this.resolve(LEGACY_RECORD_MODE) + .map(v -> this.reportLegacyConfigOption(LEGACY_RECORD_MODE, RECORD_MODE, v))) + .orElse(null); this.systemRecordingMode = Stream.of(BrowserWebDriverContainer.RecordingMode.values()) .filter(rm -> rm.toString().equals(resolvedRecordMode)) .findFirst() @@ -72,7 +106,10 @@ public Path dirForRecords() { if(this.dirForRecords == null) { - this.dirForRecords = Path.of(this.resolve(RECORD_DIR).orElse(DEFAULT_RECORD_DIR)); + this.dirForRecords = Path.of(this.resolve(RECORD_DIR) + .or(() -> this.resolve(LEGACY_RECORD_DIR) + .map(v -> this.reportLegacyConfigOption(LEGACY_RECORD_DIR, RECORD_DIR, v))) + .orElse(DEFAULT_RECORD_DIR)); final boolean wasCreated = this.dirForRecords.toFile().mkdirs(); LOG.info( "Default directory for records='{}', created={}", this.dirForRecords.toAbsolutePath(), @@ -87,7 +124,10 @@ public boolean vncEnabled() { if(this.vncEnabled == null) { - this.vncEnabled = this.resolveBool(VNC_ENABLED, false); + this.vncEnabled = this.resolveBool(VNC_ENABLED) + .or(() -> this.resolveBool(LEGACY_VNC_ENABLED) + .map(v -> this.reportLegacyConfigOption(LEGACY_VNC_ENABLED, VNC_ENABLED, v))) + .orElse(false); LOG.info("VNC enabled={}", this.vncEnabled); } return this.vncEnabled; @@ -98,7 +138,10 @@ public boolean bidiEnabled() { if(this.bidiEnabled == null) { - this.bidiEnabled = this.resolveBool(BIDI_ENABLED, true); + this.bidiEnabled = this.resolveBool(BIDI_ENABLED) + .or(() -> this.resolveBool(LEGACY_BIDI_ENABLED) + .map(v -> this.reportLegacyConfigOption(LEGACY_BIDI_ENABLED, BIDI_ENABLED, v))) + .orElse(true); LOG.info("BiDi enabled={}", this.bidiEnabled); } return this.bidiEnabled; @@ -116,11 +159,17 @@ public boolean deactivateCdpIfPossible() } @Override - public BrowserTCIFactory.BrowserConsoleLogLevel browserConsoleLogLevel() + public BrowserTCIFactory.BrowserConsoleLogLevel minBrowserConsoleLogLevel() { if(this.browserConsoleLogLevel == null) { - this.browserConsoleLogLevel = this.resolve(BROWSER_CONSOLE_LOG_LEVEL) + this.browserConsoleLogLevel = this.resolve(MIN_BROWSER_CONSOLE_LOG_LEVEL) + .or(() -> this.resolve(LEGACY_BROWSER_CONSOLE_LOG_LEVEL) + .map(v -> this.reportLegacyConfigOption( + LEGACY_BROWSER_CONSOLE_LOG_LEVEL, + MIN_BROWSER_CONSOLE_LOG_LEVEL, + v)) + ) .map(BrowserTCIFactory.BrowserConsoleLogLevel::valueOf) .orElse(BrowserTCIFactory.BrowserConsoleLogLevel.ERROR); LOG.info("BrowserConsoleLogLevel={}", this.browserConsoleLogLevel); diff --git a/spring-dao-support/pom.xml b/spring-dao-support/pom.xml index a5c9d8dc..421dd4ba 100644 --- a/spring-dao-support/pom.xml +++ b/spring-dao-support/pom.xml @@ -6,7 +6,7 @@ software.xdev.tci spring-dao-support - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT jar spring-dao-support @@ -53,7 +53,7 @@ software.xdev.tci db-jdbc - 3.4.2-SNAPSHOT + 4.0.0-SNAPSHOT @@ -78,7 +78,7 @@ org.junit.jupiter junit-jupiter - 6.1.0 + 6.1.2 test @@ -274,7 +274,7 @@ com.puppycrawl.tools checkstyle - 13.6.0 + 13.8.0 @@ -312,12 +312,12 @@ net.sourceforge.pmd pmd-core - 7.25.0 + 7.26.0 net.sourceforge.pmd pmd-java - 7.25.0 + 7.26.0